diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json deleted file mode 100644 index c4a40d4..0000000 --- a/.claude-plugin/marketplace.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "mcp-apps", - "description": "Plugins for MCP Apps development", - "owner": { - "name": "MCP Apps" - }, - "plugins": [ - { - "name": "mcp-apps", - "source": "./plugins/mcp-apps", - "description": "Skills for creating MCP Apps with the MCP Apps SDK" - } - ] -} diff --git a/.gitattributes b/.gitattributes index 309467b..cf96872 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1 @@ -# Force LF line endings for all text files (for consistent prettier formatting) -* text=auto eol=lf - package-lock.json linguist-generated=true -src/generated/** linguist-generated=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6848e4c..f6bd8d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,231 +6,27 @@ on: pull_request: branches: [main] -permissions: - contents: read - jobs: - prettier-fix: - name: Auto-fix formatting - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ github.head_ref }} - token: ${{ github.token }} - - - uses: actions/setup-node@v6 - with: - node-version: "22" - cache: npm - - - run: npm ci - - - run: npm run prettier:fix - - - name: Commit formatting changes - run: | - git diff --quiet && exit 0 - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "style: auto-fix prettier formatting" - git push - build: - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - name: Linux x64 - - os: ubuntu-24.04-arm - name: Linux ARM64 - - os: windows-latest - name: Windows x64 - - os: windows-11-arm - name: Windows ARM64 - - os: macos-latest - name: macOS ARM64 - - name: Build (${{ matrix.name }}) - runs-on: ${{ matrix.os }} - + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - name: Verify no private URLs in package-lock.json - shell: bash run: '! grep -E "\"resolved\": \"https?://" package-lock.json | grep -v registry.npmjs.org' - - name: Verify example dependency versions - shell: bash - run: node scripts/check-versions.mjs - - - uses: actions/setup-node@v6 - with: - node-version: "22" - cache: npm - - - run: npm ci - - - run: npm run build - - - name: Validate TypeDoc links - if: runner.os == 'Linux' && matrix.name == 'Linux x64' - run: npm exec typedoc -- --treatValidationWarningsAsErrors --emit none - - - run: npm run examples:build - - - name: Verify generated schemas are up-to-date - shell: bash - run: | - npm run generate:schemas - git diff --exit-code src/generated/ || (echo "Generated schemas are out of date. Run 'npm run generate:schemas' and commit." && exit 1) - - - name: Verify synced snippets are up-to-date - shell: bash - run: | - npm run sync:snippets - git diff --exit-code src/ docs/ || (echo "Synced snippets are out of date. Run 'npm run sync:snippets' and commit." && exit 1) - - - run: npm test - - - run: npm run prettier - - - name: Build MCPB bundle (pdf-server) - if: runner.os == 'Linux' && matrix.name == 'Linux x64' - run: node build-mcpb.mjs - working-directory: examples/pdf-server - - - name: Smoke-test MCPB bundle starts - if: runner.os == 'Linux' && matrix.name == 'Linux x64' - run: | - unzip -q pdf-server.mcpb -d .mcpb-smoke - node .mcpb-smoke/dist/index.js --stdio < /dev/null 2>&1 | tee /tmp/mcpb-smoke.log - grep -q "Ready" /tmp/mcpb-smoke.log - working-directory: examples/pdf-server - - dependency-isolation: - name: Dependency isolation - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-node@v6 - with: - node-version: "22" - cache: npm - - - run: npm ci - - - run: npm run test:dependency-isolation - - e2e: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v2 with: bun-version: latest - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v4 with: - node-version: "22" - cache: npm + node-version: "20" - - uses: astral-sh/setup-uv@v7 + - run: npm install - - run: npm ci + - run: npm run build:all - # Headless runs without a channel use chromium-headless-shell, so the - # full Chromium build (about 165 MB) would only be downloaded, never run. - - name: Install Playwright browsers - run: npx playwright install --with-deps --only-shell chromium + - run: npm test - - name: Run E2E tests - run: npx playwright test --reporter=list - - - name: Upload test results - uses: actions/upload-artifact@v6 - if: failure() - with: - name: test-results - path: test-results/ - retention-days: 7 - - # Test build in Windows WSL (Ubuntu) - build-wsl: - name: Build (Windows WSL) - runs-on: windows-latest - - steps: - - uses: actions/checkout@v6 - - - uses: Vampire/setup-wsl@v6 - with: - distribution: Ubuntu-24.04 - - - name: Install Node.js in WSL - shell: wsl-bash {0} - run: | - sudo apt-get update - curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - - sudo apt-get install -y nodejs - - # The checkout lives on the Windows drive, which WSL reaches over 9P; - # writing node_modules there is what makes this job slow (npm ci alone - # took about 3 minutes). Copy the tree onto the distro's ext4 filesystem - # first, as Microsoft's WSL guidance recommends for anything I/O heavy. - - name: Build and test in WSL - shell: wsl-bash {0} - run: | - cp -r "$(pwd)" ~/ext-apps && cd ~/ext-apps - npm ci - npm run build - npm run examples:build - npm test - npm run prettier - - # Test that the package can be installed from git (triggers prepare script) - test-git-install: - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - name: Linux x64 - - os: ubuntu-24.04-arm - name: Linux ARM64 - - os: windows-latest - name: Windows x64 - - os: windows-11-arm - name: Windows ARM64 - - os: macos-latest - name: macOS ARM64 - - name: Test git install (${{ matrix.name }}) - runs-on: ${{ matrix.os }} - - steps: - - uses: actions/setup-node@v6 - with: - node-version: "22" - - - name: Create test project and install from git - shell: bash - env: - HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name || github.repository }} - HEAD_REF: ${{ github.head_ref || github.ref_name }} - run: | - mkdir test-project - cd test-project - npm init -y - # Install from the PR branch (use head repo for fork PRs) - npm install "git+https://github.com/${HEAD_REPO}#${HEAD_REF}" - # Verify the package is usable (ESM import) - node --input-type=module -e "import { App } from '@modelcontextprotocol/ext-apps'; console.log('Import successful:', typeof App)" + - run: npm run prettier diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml deleted file mode 100644 index 3a170cd..0000000 --- a/.github/workflows/docs-preview.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Docs Preview - -on: - pull_request: - types: [opened, synchronize, reopened, closed] - paths: - - "docs/**" - - "src/**" - - "README.md" - - "typedoc.config.mjs" - - "scripts/typedoc-*.mjs" - - ".github/workflows/docs-preview.yml" - -permissions: - contents: read - pull-requests: write - -concurrency: - group: docs-preview-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - build-and-deploy: - # Refuse to run for fork PRs โ€” forks do not have access to the Cloudflare - # secrets, so deploys would fail anyway. - if: >- - github.event.action != 'closed' && - github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-node@v6 - with: - node-version: "22" - cache: npm - - - run: npm ci - - - run: npm run build - - - run: npm run docs - - - name: Deploy preview - uses: modelcontextprotocol/actions/cloudflare-pages-preview/deploy@main - with: - directory: docs - project-name: mcp-ext-apps-docs-preview - api-token: ${{ secrets.CF_PAGES_PREVIEW_API_TOKEN }} - account-id: ${{ secrets.CF_PAGES_PREVIEW_ACCOUNT_ID }} - comment-title: "๐Ÿ“– Docs Preview Deployed" - comment-marker: "" - - cleanup: - if: >- - github.event.action == 'closed' && - github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - steps: - - uses: modelcontextprotocol/actions/cloudflare-pages-preview/cleanup@main - with: - project-name: mcp-ext-apps-docs-preview - api-token: ${{ secrets.CF_PAGES_PREVIEW_API_TOKEN }} - account-id: ${{ secrets.CF_PAGES_PREVIEW_ACCOUNT_ID }} - comment-marker: "" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a046c29..d7ad47b 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -2,8 +2,9 @@ name: Deploy Documentation on: workflow_dispatch: - release: - types: [published] + # Future: Uncomment to auto-deploy on release + # release: + # types: [published] permissions: contents: write @@ -12,13 +13,12 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v4 with: - node-version: "22" - cache: npm - - run: npm ci + node-version: "20" + - run: npm install - run: npm run build - run: npm run docs - uses: peaceiris/actions-gh-pages@v4 @@ -26,5 +26,4 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./docs publish_branch: gh-pages - keep_files: true - cname: apps.extensions.modelcontextprotocol.io + force_orphan: true diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index ed36c15..101e40c 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -14,11 +14,11 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v4 with: node-version: "22" cache: npm @@ -31,25 +31,21 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v4 with: node-version: "22" cache: npm - run: npm ci - run: npm test - # All publish jobs depend directly on [build, test] (not on each other) so - # they all enter "waiting for approval" together and can be approved in a - # single "Review deployments" click. - publish: runs-on: ubuntu-latest if: github.event_name == 'release' - environment: Release + environment: release needs: [build, test] permissions: @@ -57,11 +53,11 @@ jobs: id-token: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v4 with: node-version: "22" cache: npm @@ -86,98 +82,4 @@ jobs: - run: npm publish --provenance --access public ${{ steps.npm-tag.outputs.tag }} env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_SECRET }} - - publish-examples: - runs-on: ubuntu-latest - if: github.event_name == 'release' - environment: Release - needs: [build, test] - - permissions: - contents: read - id-token: write - - strategy: - fail-fast: false - matrix: - example: - - basic-server-preact - - basic-server-react - - basic-server-solid - - basic-server-svelte - - basic-server-vanillajs - - basic-server-vue - - budget-allocator-server - - cohort-heatmap-server - - customer-segmentation-server - - debug-server - - lazy-auth-server - - map-server - - pdf-server - - scenario-modeler-server - - shadertoy-server - - sheet-music-server - - system-monitor-server - - threejs-server - - transcript-server - - video-resource-server - - wiki-explorer-server - - steps: - - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - uses: actions/setup-node@v6 - with: - node-version: "22" - cache: npm - registry-url: "https://registry.npmjs.org" - - run: npm ci - - - name: Build example - run: npm run build --workspace examples/${{ matrix.example }} - - - name: Determine npm tag - id: npm-tag - run: | - VERSION=$(node -p "require('./package.json').version") - if [[ "$VERSION" == *"-"* ]]; then - echo "tag=--tag beta" >> $GITHUB_OUTPUT - else - echo "tag=" >> $GITHUB_OUTPUT - fi - - - name: Publish example - run: npm publish --workspace examples/${{ matrix.example }} --provenance --access public ${{ steps.npm-tag.outputs.tag }} - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_SECRET }} - - publish-mcpb: - runs-on: ubuntu-latest - if: github.event_name == 'release' - environment: Release - needs: [build, test] - - permissions: - contents: write - - steps: - - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - uses: actions/setup-node@v6 - with: - node-version: "22" - cache: npm - - run: npm ci - - - name: Build pdf-server + MCPB bundle - run: npm run build:mcpb --workspace examples/pdf-server - - - name: Upload MCPB to release - run: gh release upload "${{ github.event.release.tag_name }}" examples/pdf-server/*.mcpb --clobber - env: - GH_TOKEN: ${{ github.token }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 478c5b8..d1614b1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,35 +13,11 @@ jobs: pkg-publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm run build - - run: npm run examples:build - - run: | - npx pkg-pr-new publish \ - . \ - ./examples/basic-server-preact \ - ./examples/basic-server-react \ - ./examples/basic-server-solid \ - ./examples/basic-server-svelte \ - ./examples/basic-server-vanillajs \ - ./examples/basic-server-vue \ - ./examples/budget-allocator-server \ - ./examples/cohort-heatmap-server \ - ./examples/customer-segmentation-server \ - ./examples/debug-server \ - ./examples/lazy-auth-server \ - ./examples/map-server \ - ./examples/pdf-server \ - ./examples/scenario-modeler-server \ - ./examples/shadertoy-server \ - ./examples/sheet-music-server \ - ./examples/system-monitor-server \ - ./examples/threejs-server \ - ./examples/transcript-server \ - ./examples/video-resource-server \ - ./examples/wiki-explorer-server + - run: npx pkg-pr-new publish diff --git a/.github/workflows/update-snapshots.yml b/.github/workflows/update-snapshots.yml deleted file mode 100644 index 182f01a..0000000 --- a/.github/workflows/update-snapshots.yml +++ /dev/null @@ -1,108 +0,0 @@ -name: Update E2E Snapshots - -on: - workflow_dispatch: - inputs: - branch: - description: "Branch to update snapshots on" - required: true - default: "main" - issue_comment: - types: [created] - -permissions: - contents: write - pull-requests: write - -jobs: - update-snapshots: - # Run on workflow_dispatch OR when a maintainer comments "/update-snapshots" on a PR. - # author_association check prevents arbitrary commenters from triggering a job with - # contents:write that pushes a [skip ci] commit to the PR branch. - if: > - github.event_name == 'workflow_dispatch' || - (github.event.issue.pull_request && - contains(github.event.comment.body, '/update-snapshots') && - contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)) - runs-on: ubuntu-latest - steps: - - name: Get PR branch - if: github.event_name == 'issue_comment' - id: pr - uses: actions/github-script@v8 - with: - script: | - const pr = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number - }); - core.setOutput('ref', pr.data.head.ref); - core.setOutput('sha', pr.data.head.sha); - - - name: Add reaction to comment - if: github.event_name == 'issue_comment' - uses: actions/github-script@v8 - with: - script: | - await github.rest.reactions.createForIssueComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: context.payload.comment.id, - content: 'rocket' - }); - - - uses: actions/checkout@v6 - with: - ref: ${{ github.event.inputs.branch || steps.pr.outputs.ref || github.ref }} - token: ${{ secrets.GITHUB_TOKEN }} - - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - uses: actions/setup-node@v6 - with: - node-version: "22" - cache: npm - - - uses: astral-sh/setup-uv@v7 - - - run: npm ci - - - name: Install Playwright browsers - run: npx playwright install --with-deps chromium - - - name: Update snapshots - run: npx playwright test --update-snapshots --reporter=list - - - name: Commit updated snapshots - id: commit - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add tests/e2e/**/*.png - if git diff --staged --quiet; then - echo "changed=false" >> $GITHUB_OUTPUT - echo "No snapshot changes to commit" - else - git commit -m "chore: update e2e snapshots [skip ci]" - git push - echo "changed=true" >> $GITHUB_OUTPUT - fi - - - name: Comment on PR - if: github.event_name == 'issue_comment' - uses: actions/github-script@v8 - with: - script: | - const changed = '${{ steps.commit.outputs.changed }}' === 'true'; - const body = changed - ? 'โœ… Snapshots updated and pushed to this branch.' - : 'โœ… No snapshot changes needed - all snapshots are up to date.'; - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body - }); diff --git a/.gitignore b/.gitignore index 5b5486b..dc9ad99 100644 --- a/.gitignore +++ b/.gitignore @@ -6,15 +6,3 @@ bun.lockb .vscode/ docs/api/ tmp/ -intermediate-findings/ - -# Playwright -playwright-report/ -test-results/ -__pycache__/ -*.pyc - -# MCPB bundles (built artifacts) -*.mcpb -.mcpb-stage/ -.mcpb-smoke/ \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit index ae5b3c7..cffd562 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,29 +1,2 @@ -# Load Node.js environment for GUI apps (GitHub Desktop, etc.) -# that don't inherit shell PATH -export NVM_DIR="$HOME/.nvm" -[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" # nvm -[ -s "$HOME/.fnm/fnm" ] && eval "$(~/.fnm/fnm env)" # fnm -[ -s "$HOME/.volta/bin/volta" ] && export PATH="$HOME/.volta/bin:$PATH" # volta -[ -d "/opt/homebrew/bin" ] && export PATH="/opt/homebrew/bin:$PATH" # homebrew (macOS ARM) -[ -d "/usr/local/bin" ] && export PATH="/usr/local/bin:$PATH" # homebrew (macOS Intel) - -# Verify no private registry URLs in package-lock.json -if grep -E '"resolved": "https?://' package-lock.json | grep -v registry.npmjs.org > /dev/null; then - echo "ERROR: package-lock.json contains non-npmjs.org URLs" - echo "Run: docker run --rm -i -v \$PWD:/src -w /src node:latest npm i --registry=https://registry.npmjs.org/" - exit 1 -fi - -# Capture staged files so we only re-stage what the user intended to commit -# (avoids sweeping unrelated WIP into the commit). --diff-filter=d skips -# deletions so the xargs git add below doesn't fail on removed paths. -STAGED=$(git diff --name-only --cached --diff-filter=d) - npm run build:all npm run prettier:fix - -# Re-stage originally-staged files (prettier may have reformatted them) -echo "$STAGED" | xargs -r git add - -# Also stage generated files (regenerated by build:all from spec.types.ts) -git add src/generated/ diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 214c29d..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -registry=https://registry.npmjs.org/ diff --git a/.prettierignore b/.prettierignore index c51df7b..e2a4cdb 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,9 +1,6 @@ examples/basic-host/**/*.ts examples/basic-host/**/*.tsx -examples/basic-server-*/**/*.ts -examples/basic-server-*/**/*.tsx -examples/quickstart/**/*.ts -**/vendor/** -**/.build/** -**/.claude/** -SKILL.md +examples/basic-server-react/**/*.ts +examples/basic-server-react/**/*.tsx +examples/basic-server-vanillajs/**/*.ts +examples/basic-server-vanillajs/**/*.tsx diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index a64f001..0000000 --- a/.prettierrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "semi": true, - "trailingComma": "all", - "singleQuote": false, - "printWidth": 80, - "tabWidth": 2 -} diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 219b947..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,103 +0,0 @@ -# MCP Apps SDK - -## Project Overview - -MCP Apps SDK (`@modelcontextprotocol/ext-apps`) enables MCP servers to display interactive UIs in conversational clients. - -Key abstractions: - -- **View** - UI running in an iframe, uses `App` class with `PostMessageTransport` to communicate with host -- **Host** - Chat client embedding the iframe, uses `AppBridge` class to proxy MCP requests -- **Server** - MCP server that registers tools/resources with UI metadata - -Specification (stable): `specification/2026-01-26/apps.mdx` - -## Commands - -```bash -# Install dependencies -npm install - -# Build the SDK only (generates schemas + bundles, does not build examples) -npm run build - -# Build everything (SDK + all examples) -npm run build:all - -# Type check + build a single example -npm run --workspace examples/ build - -# Run all examples (starts server at http://localhost:8080) -npm start - -# Run E2E tests (primary testing mechanism - starts examples server automatically) -npm run test:e2e - -# Run unit tests (E2E tests have broader coverage; unit tests cover specific modules) -npm test - -# Check JSDoc comment syntax and `{@link}` references -npm exec typedoc -- --treatValidationWarningsAsErrors --emit none - -# Regenerate package-lock.json -# Note: repo .npmrc pins registry to npmjs.org, so a plain `npm i` is safe even -# if your global npm config points elsewhere. The Docker step below is optional -# โ€” it locks linux-amd64 optionalDependencies (sharp, rollup, bun) for CI. -rm -fR package-lock.json node_modules && \ - docker run --rm -it --platform linux/amd64 -v $PWD:/src:rw -w /src node:latest npm i && \ - rm -fR node_modules && \ - npm i --cache=~/.npm-mcp-apps --registry=https://registry.npmjs.org/ -``` - -## Architecture - -### SDK Entry Points - -- `@modelcontextprotocol/ext-apps` - Main SDK for Apps (`App` class, `PostMessageTransport`) -- `@modelcontextprotocol/ext-apps/react` - React hooks (`useApp`, `useHostStyleVariables`, etc.) -- `@modelcontextprotocol/ext-apps/app-bridge` - SDK for hosts (`AppBridge` class) -- `@modelcontextprotocol/ext-apps/server` - Server helpers (`registerAppTool`, `registerAppResource`) - -### Key Source Files - -- `src/app.ts` - `App` subclasses `Protocol` from `@modelcontextprotocol/client`, handles View initialization, tool calls, and messaging -- `src/app-bridge.ts` - `AppBridge` subclasses `Protocol` from `@modelcontextprotocol/client` for the iframe channel and proxies through a separate outer `Client` -- `src/server/index.ts` - Helpers for MCP servers to register tools/resources with UI metadata -- `src/types.ts` - Protocol types re-exported from `spec.types.ts` and Zod schemas from `generated/schema.ts` (auto-generated during build) -- `src/message-transport.ts` - `PostMessageTransport` for iframe communication -- `src/react/` - React hooks: `useApp`, `useHostStyles`, `useAutoResize`, `useDocumentTheme` - -### Protocol Flow - -``` -View (App/Protocol) <--PostMessageTransport--> Host (AppBridge/Protocol) <--separate MCP Client--> MCP Server -``` - -1. Host creates iframe with view HTML -2. View creates `App` instance and calls `connect()` with `PostMessageTransport` -3. View sends `ui/initialize` (the only handshake on the iframe channel) and receives authoritative Apps capabilities and context -4. Host sends `sendToolInput()` with tool arguments after initialization -5. View can call server tools via `app.callServerTool()` or send messages via `app.sendMessage()` -6. Host sends `sendToolResult()` when tool execution completes -7. Host calls `teardownResource()` before unmounting iframe - -## Documentation - -JSDoc `@example` tags should pull type-checked code from companion `.examples.ts` files (e.g., `app.ts` โ†’ `app.examples.ts`). Use ` ```ts source="./file.examples.ts#regionName" ` fences referencing `//#region regionName` blocks; region names follow `exportedName_variant` or `ClassName_methodName_variant` pattern (e.g., `useApp_basicUsage`, `App_hostCapabilities_checkAfterConnection`). For whole-file inclusion (any file type), omit the `#regionName`. Run `npm run sync:snippets` to sync. - -Standalone docs in `docs/` (listed in `typedoc.config.mjs` `projectDocuments`) can also have type-checked companion `.ts`/`.tsx` files using the same pattern. - -## Full Examples - -Uses npm workspaces. Full examples in `examples/` are separate packages: - -- `basic-server-*` - Starter templates (vanillajs, react, vue, svelte, preact, solid). Use these as the basis for new examples. -- `basic-host` - Reference host implementation -- Other examples showcase specific features (charts, 3D, video, etc.) - -## Claude Code Plugin - -The `plugins/mcp-apps/` directory contains a Claude Code plugin distributed via the plugin marketplace. It provides the following Claude Code skills files: - -- `plugins/mcp-apps/skills/create-mcp-app/SKILL.md` โ€” for creating an MCP App -- `plugins/mcp-apps/skills/migrate-oai-app/SKILL.md` โ€” for migrating an app from the OpenAI Apps SDK to the MCP Apps SDK diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 47dc3e3..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e28dcd1..8ec033b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,15 +25,6 @@ We welcome contributions to the MCP Apps SDK! This document outlines the process - Update documentation as needed - Keep changes focused and atomic - Provide a clear description of changes -- **Keep "Allow edits by maintainers" checked** when opening your PR โ€” this lets maintainers rebase your branch and lets the [`/update-snapshots` workflow](#updating-snapshots-in-ci) push updated screenshots to it - -## Examples Policy - -The examples in `examples/` are maintained by the core team. **We do not accept pull requests that add new example servers from outside contributors.** Each example carries an ongoing maintenance cost โ€” keeping it building, tested, and up to date with SDK changes โ€” so we are deliberate about what we add. - -If you have an idea for an example that would provide significant educational value, please [open an issue](https://github.com/modelcontextprotocol/ext-apps/issues) describing what the example would demonstrate and why it would be useful, and maintainers will evaluate the request. - -PRs that add new examples without a prior approved issue may be closed. ## Running Examples @@ -49,460 +40,9 @@ Or build and run examples: npm run examples:start ``` -### With MCP Clients - -To use these examples with MCP clients that support the stdio transport (such as Claude Desktop or VS Code), add this MCP server configuration to your client's settings: - -
-MCP client configuration for all examples (using stdio) - -```json -{ - "mcpServers": { - "basic-react": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-basic-react", - "--stdio" - ] - }, - "basic-vanillajs": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-basic-vanillajs", - "--stdio" - ] - }, - "basic-vue": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-basic-vue", - "--stdio" - ] - }, - "basic-svelte": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-basic-svelte", - "--stdio" - ] - }, - "basic-preact": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-basic-preact", - "--stdio" - ] - }, - "basic-solid": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-basic-solid", - "--stdio" - ] - }, - "budget-allocator": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-budget-allocator", - "--stdio" - ] - }, - "cohort-heatmap": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-cohort-heatmap", - "--stdio" - ] - }, - "customer-segmentation": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-customer-segmentation", - "--stdio" - ] - }, - "map": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-map", - "--stdio" - ] - }, - "pdf": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-pdf", - "--stdio" - ] - }, - "scenario-modeler": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-scenario-modeler", - "--stdio" - ] - }, - "shadertoy": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-shadertoy", - "--stdio" - ] - }, - "sheet-music": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-sheet-music", - "--stdio" - ] - }, - "system-monitor": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-system-monitor", - "--stdio" - ] - }, - "threejs": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-threejs", - "--stdio" - ] - }, - "transcript": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-transcript", - "--stdio" - ] - }, - "video-resource": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-video-resource", - "--stdio" - ] - }, - "wiki-explorer": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-wiki-explorer", - "--stdio" - ] - }, - "qr": { - "command": "uv", - "args": [ - "run", - "/path/to/ext-apps/examples/qr-server/server.py", - "--stdio" - ] - }, - "say": { - "command": "uv", - "args": [ - "run", - "--default-index", - "https://pypi.org/simple", - "https://raw.githubusercontent.com/modelcontextprotocol/ext-apps/refs/heads/main/examples/say-server/server.py", - "--stdio" - ] - } - } -} -``` - -
- -> [!NOTE] -> The `qr` server requires cloning the repository first. See [qr-server README](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/qr-server) for details. - -### Local Development - -To test local modifications with MCP clients, first clone and install the repository: - -```bash -git clone https://github.com/modelcontextprotocol/ext-apps.git -cd ext-apps -npm install -``` - -Then configure your MCP client to build and run the local server. Replace `~/src/ext-apps` with your actual clone path. - -Most example servers have a `start:stdio` script that builds and launches in stdio mode: - -
-MCP client configuration for local development (all examples) - -```json -{ - "mcpServers": { - "basic-react": { - "command": "bash", - "args": [ - "-c", - "cd ~/src/ext-apps/examples/basic-server-react && npm --silent run start:stdio" - ] - }, - "basic-vanillajs": { - "command": "bash", - "args": [ - "-c", - "cd ~/src/ext-apps/examples/basic-server-vanillajs && npm --silent run start:stdio" - ] - }, - "basic-vue": { - "command": "bash", - "args": [ - "-c", - "cd ~/src/ext-apps/examples/basic-server-vue && npm --silent run start:stdio" - ] - }, - "basic-svelte": { - "command": "bash", - "args": [ - "-c", - "cd ~/src/ext-apps/examples/basic-server-svelte && npm --silent run start:stdio" - ] - }, - "basic-preact": { - "command": "bash", - "args": [ - "-c", - "cd ~/src/ext-apps/examples/basic-server-preact && npm --silent run start:stdio" - ] - }, - "basic-solid": { - "command": "bash", - "args": [ - "-c", - "cd ~/src/ext-apps/examples/basic-server-solid && npm --silent run start:stdio" - ] - }, - "budget-allocator": { - "command": "bash", - "args": [ - "-c", - "cd ~/src/ext-apps/examples/budget-allocator-server && npm --silent run start:stdio" - ] - }, - "cohort-heatmap": { - "command": "bash", - "args": [ - "-c", - "cd ~/src/ext-apps/examples/cohort-heatmap-server && npm --silent run start:stdio" - ] - }, - "customer-segmentation": { - "command": "bash", - "args": [ - "-c", - "cd ~/src/ext-apps/examples/customer-segmentation-server && npm --silent run start:stdio" - ] - }, - "map": { - "command": "bash", - "args": [ - "-c", - "cd ~/src/ext-apps/examples/map-server && npm --silent run start:stdio" - ] - }, - "pdf": { - "command": "bash", - "args": [ - "-c", - "cd ~/src/ext-apps/examples/pdf-server && npm --silent run start:stdio" - ] - }, - "scenario-modeler": { - "command": "bash", - "args": [ - "-c", - "cd ~/src/ext-apps/examples/scenario-modeler-server && npm --silent run start:stdio" - ] - }, - "shadertoy": { - "command": "bash", - "args": [ - "-c", - "cd ~/src/ext-apps/examples/shadertoy-server && npm --silent run start:stdio" - ] - }, - "sheet-music": { - "command": "bash", - "args": [ - "-c", - "cd ~/src/ext-apps/examples/sheet-music-server && npm --silent run start:stdio" - ] - }, - "system-monitor": { - "command": "bash", - "args": [ - "-c", - "cd ~/src/ext-apps/examples/system-monitor-server && npm --silent run start:stdio" - ] - }, - "threejs": { - "command": "bash", - "args": [ - "-c", - "cd ~/src/ext-apps/examples/threejs-server && npm --silent run start:stdio" - ] - }, - "transcript": { - "command": "bash", - "args": [ - "-c", - "cd ~/src/ext-apps/examples/transcript-server && npm --silent run start:stdio" - ] - }, - "video-resource": { - "command": "bash", - "args": [ - "-c", - "cd ~/src/ext-apps/examples/video-resource-server && npm --silent run start:stdio" - ] - }, - "wiki-explorer": { - "command": "bash", - "args": [ - "-c", - "cd ~/src/ext-apps/examples/wiki-explorer-server && npm --silent run start:stdio" - ] - }, - "qr": { - "command": "bash", - "args": [ - "-c", - "uv run ~/src/ext-apps/examples/qr-server/server.py --stdio" - ] - }, - "say": { - "command": "bash", - "args": [ - "-c", - "uv run --index https://pypi.org/simple ~/src/ext-apps/examples/say-server/server.py --stdio" - ] - } - } -} -``` - -
- -This configuration rebuilds each server on launch, ensuring your local changes are picked up. - -## Testing - -### Unit Tests - -Run unit tests with Bun: - -```bash -npm test -``` - -### E2E Tests - -E2E tests use Playwright to verify all example servers work correctly with screenshot comparisons. - -```bash -# Run all E2E tests -npm run test:e2e - -# Run a specific server's tests -npm run test:e2e -- --grep "Budget Allocator" - -# Run tests in interactive UI mode -npm run test:e2e:ui -``` - -### Updating Golden Screenshots - -When UI changes are intentional, update the golden screenshots: - -```bash -# Update all screenshots -npm run test:e2e:update - -# Update screenshots for a specific server -npm run test:e2e:update -- --grep "Three.js" -``` - -**Note**: Golden screenshots are platform-agnostic. Tests use canvas masking and tolerance thresholds to handle minor cross-platform rendering differences. - -#### Updating Snapshots in CI - -If E2E tests fail in CI due to screenshot mismatches, you can update snapshots directly from your PR: - -1. Comment `/update-snapshots` on the PR -2. The workflow will update snapshots and push to your branch -3. A comment will confirm when complete - -Alternatively, use the [workflow dispatch](https://github.com/modelcontextprotocol/ext-apps/actions/workflows/update-snapshots.yml) to manually trigger updates for any branch. - ## Code of Conduct -This project follows our [Code of Conduct](https://github.com/modelcontextprotocol/.github/blob/main/CODE_OF_CONDUCT.md). Please review it before contributing. +This project follows our [Code of Conduct](CODE_OF_CONDUCT.md). Please review it before contributing. ## Reporting Issues @@ -520,36 +60,54 @@ Please review our [Security Policy](SECURITY.md) for reporting security vulnerab ### Repository Setup -This repository uses [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/) with OIDC - no secrets required. - Before publishing releases, ensure the following are configured: -1. **Trusted publisher on npm**: Configure the package to trust this GitHub repository - - Go to https://www.npmjs.com/package/@modelcontextprotocol/ext-apps/access - - Under "Trusted Publishers", click "Add trusted publisher" - - Select "GitHub Actions" - - Repository: `modelcontextprotocol/ext-apps` - - Workflow filename: `npm-publish.yml` - - Environment: `Release` (optional, for additional protection) +1. **NPM_TOKEN secret**: Add an npm automation token to the repository secrets + - Go to Settings ๏ฟฝ Secrets and variables ๏ฟฝ Actions + - Create a new secret named `NPM_TOKEN` + - Value: an npm automation token with publish permissions for `@modelcontextprotocol/ext-apps` -2. **`Release` environment** (optional): Create a protected environment for additional safeguards - - Go to Settings > Environments > New environment - - Name it `Release` +2. **`release` environment** (optional): Create a protected environment for additional safeguards + - Go to Settings ๏ฟฝ Environments ๏ฟฝ New environment + - Name it `release` - Add required reviewers or other protection rules as needed ### Publishing a Release -1. **Bump the version** across the root and all workspace packages: +Releases are published automatically via GitHub Actions when a GitHub Release is created. + +#### Steps to publish: + +1. **Update the version** in `package.json`: ```bash - npm run bump -- minor # or: patch | major | prerelease --preid=beta | 1.7.0 + # For a regular release + npm version patch # or minor, or major + + # For a beta release + npm version prerelease --preid=beta ``` - Commit and open a PR with a grouped changelog in the body (see prior `chore: bump โ€ฆ` PRs for the format). +2. **Commit the version bump** (if not done by `npm version`): -2. **Merge the PR**, then [draft a GitHub Release](https://github.com/modelcontextprotocol/ext-apps/releases/new) โ€” create a `vX.Y.Z` tag on `main`, paste the changelog, publish. + ```bash + git add package.json + git commit -m "Bump version to X.Y.Z" + git push origin main + ``` -3. **Approve the deployment** โ€” publishing the Release triggers the [npm-publish workflow](https://github.com/modelcontextprotocol/ext-apps/actions/workflows/npm-publish.yml); all publish jobs wait together for a single "Review deployments" approval. +3. **Create a GitHub Release**: + - Go to [Releases](https://github.com/modelcontextprotocol/ext-apps/releases) + - Click "Draft a new release" + - Create a new tag matching the version (e.g., `v0.1.0`) + - Set the target branch (usually `main`) + - Write release notes describing the changes + - Click "Publish release" + +4. **Monitor the workflow**: + - The [npm-publish workflow](https://github.com/modelcontextprotocol/ext-apps/actions/workflows/npm-publish.yml) will trigger automatically + - It runs build and test jobs before publishing + - On success, the package is published to npm with provenance #### npm Tags @@ -573,7 +131,7 @@ To release a patch for an older version: ### Testing Pre-releases -Every commit and PR automatically publishes a preview package via [pkg-pr-new](https://github.com/stackblitz-labs/pkg.pr.new). Check the PR comments or workflow logs for the install command. +Every commit and PR automatically publishes a preview package via [pkg-pr-new](https://github.com/pkg-pr-new/pkg-pr-new). Check the PR comments or workflow logs for the install command. --- diff --git a/LICENSE b/LICENSE index 4a93985..ad75c9e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,193 +1,6 @@ -The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. - -Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License. - -No rights beyond those granted by the applicable original license are conveyed for such contributions. - ---- - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to the Licensor for inclusion in the Work by the copyright - owner or by an individual or Legal Entity authorized to submit on behalf - of the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - ---- - MIT License -Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC. +Copyright (c) 2025 Anthropic, PBC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -206,11 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---- - -Creative Commons Attribution 4.0 International (CC-BY-4.0) - -Documentation in this project (excluding specifications) is licensed under -CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for -the full license text. diff --git a/README.md b/README.md index ab4004d..41b3955 100644 --- a/README.md +++ b/README.md @@ -1,284 +1,75 @@ - -
- - - - MCP Apps - -

MCP Apps

-

- Build interactive UIs for MCP tools โ€” charts, forms, dashboards โ€” that render inline in Claude, ChatGPT and any other compliant chat client. -

- Why - ยท - Quickstart - ยท - API Docs - ยท - Spec - ยท - Contributing -

-
+# @modelcontextprotocol/ext-apps -

- CI - License: Apache 2.0 - npm version - npm downloads - GitHub stars - API Documentation -

+[![API Documentation](https://img.shields.io/badge/docs-API%20Reference-blue)](https://modelcontextprotocol.github.io/ext-apps/api/) -

- MCP Apps demo -
Excalidraw built with MCP Apps, running in Claude -

+This repo contains the SDK and [specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx) for MCP Apps Extension ([SEP-1865](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865)). -## Table of Contents +MCP Apps are a proposed standard inspired by [MCP-UI](https://mcpui.dev/) and [OpenAI's Apps SDK](https://developers.openai.com/apps-sdk/) to allow MCP Servers to display interactive UI elements in conversational MCP clients / chatbots. -- [Build with Agent Skills](#build-with-agent-skills) -- [Supported Clients](#supported-clients) -- [Why MCP Apps?](#why-mcp-apps) -- [How It Works](#how-it-works) -- [Getting Started](#getting-started) -- [Using the SDK](#using-the-sdk) -- [Examples](#examples) -- [Specification](#specification) -- [Resources](#resources) -- [Contributing](#contributing) +## Overview -## Build with Agent Skills +This SDK serves two audiences: -The fastest way to build an MCP App is to let your AI coding agent do it. This -repo ships four [Agent Skills](https://agentskills.io/) โ€” install them once, -then just ask: +### App Developers -| Skill | What it does | Try it | -| --------------------------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------- | -| [`create-mcp-app`](./plugins/mcp-apps/skills/create-mcp-app/SKILL.md) | Scaffolds a new MCP App with an interactive UI from scratch | _"Create an MCP App"_ | -| [`migrate-oai-app`](./plugins/mcp-apps/skills/migrate-oai-app/SKILL.md) | Converts an existing OpenAI App to use MCP Apps | _"Migrate from OpenAI Apps SDK"_ | -| [`add-app-to-server`](./plugins/mcp-apps/skills/add-app-to-server/SKILL.md) | Adds interactive UI to an existing MCP server's tools | _"Add UI to my MCP server"_ | -| [`convert-web-app`](./plugins/mcp-apps/skills/convert-web-app/SKILL.md) | Turns an existing web app into a hybrid web + MCP App | _"Add MCP App support to my web app"_ | +Build interactive UIs that run inside MCP-enabled chat clients. -### Install the Skills +- **SDK for Apps**: `@modelcontextprotocol/ext-apps` โ€” [API Docs](https://modelcontextprotocol.github.io/ext-apps/api/modules/app.html) +- **React hooks**: `@modelcontextprotocol/ext-apps/react` โ€” [API Docs](https://modelcontextprotocol.github.io/ext-apps/api/modules/_modelcontextprotocol_ext-apps_react.html) -**Claude Code** โ€” install via the plugin marketplace: +### Host Developers -``` -/plugin marketplace add modelcontextprotocol/ext-apps -/plugin install mcp-apps@modelcontextprotocol-ext-apps +Embed and communicate with MCP Apps in your chat application. + +- **SDK for Hosts**: `@modelcontextprotocol/ext-apps/app-bridge` โ€” [API Docs](https://modelcontextprotocol.github.io/ext-apps/api/modules/app-bridge.html) + +There's no _supported_ host implementation in this repo (beyond the [examples/basic-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example). + +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. + +## Installation + +This repo is in flux and isn't published to npm yet. When it is, it will use the `@modelcontextprotocol/ext-apps` package. + +In the meantime you can depend on the SDK library in a Node.js project by installing it with its git URL: + +```bash +npm install -S git+https://github.com/modelcontextprotocol/ext-apps.git ``` -**Other agents** โ€” any AI coding agent that supports -[Agent Skills](https://agentskills.io/) can use these skills. See the -[agent skills guide](./docs/agent-skills.md) for manual installation -instructions. +Your `package.json` will then look like: -Once installed, verify by asking your agent _"What skills do you have?"_ โ€” you -should see `create-mcp-app`, `migrate-oai-app`, `add-app-to-server`, and -`convert-web-app` in the list. Then just ask it to create or migrate an app and -it will guide you through the rest. - -## Supported Clients - -

- ChatGPT - Claude - VS Code - Goose - Postman - MCPJam - mcp-use - Alpic Playground -

+```json +{ + "dependencies": { + "@modelcontextprotocol/ext-apps": "git+https://github.com/modelcontextprotocol/ext-apps.git" + } +} +``` > [!NOTE] -> MCP Apps is an extension to the -> [core MCP specification](https://modelcontextprotocol.io/specification). Host -> support varies โ€” see the -> [clients page](https://modelcontextprotocol.io/clients) for the full list. - -## Why MCP Apps? - -MCP tools return text and structured data. That works for many cases, but not -when you need an interactive UI, like a chart, form, design canvas or video player. - -MCP Apps provide a standardized way to deliver interactive UIs from MCP servers. -Your UI renders inline in the conversation, in context, in any compliant host. - -## How It Works - -MCP Apps extend the Model Context Protocol by letting tools declare UI -resources: - -1. **Tool definition** โ€” Your tool declares a `ui://` resource containing its - HTML interface -2. **Tool call** โ€” The LLM calls the tool on your server -3. **Host renders** โ€” The host fetches the resource and displays it in a - sandboxed iframe -4. **Bidirectional communication** โ€” The host passes tool data to the UI via - notifications, and the UI can call other tools through the host - -## Getting Started - -Requires Node.js 20+. The base MCP SDK packages are `^2.0.0` peers of -`ext-apps` (`@modelcontextprotocol/core` is a required peer that `client` already depends on, so npm installs it for you). - -For a View or host: - -```bash -npm install -S @modelcontextprotocol/ext-apps \ - @modelcontextprotocol/client@^2.0.0 \ - zod@^4.2.0 -``` - -For an MCP server, add the server package (and, for HTTP transports, the Node -and Express adapters): - -```bash -npm install -S @modelcontextprotocol/ext-apps \ - @modelcontextprotocol/client@^2.0.0 \ - @modelcontextprotocol/server@^2.0.0 \ - @modelcontextprotocol/node@^2.0.0 \ - @modelcontextprotocol/express@^2.0.0 \ - zod@^4.2.0 -``` - -The wire protocol is unchanged between `ext-apps` 1.x and 2.x: a 2.x View works -in a 1.x host and a 2.x host renders 1.x Views. See the -[migration guide](https://apps.extensions.modelcontextprotocol.io/api/documents/migrate-to-v2.html) -when upgrading from 1.x. - -**New here?** Start with the -[Quickstart Guide](https://apps.extensions.modelcontextprotocol.io/api/documents/quickstart.html) -to build your first MCP App. - -## Using the SDK - -The SDK serves three roles: app developers building interactive Views, host -developers embedding those Views, and MCP server authors registering tools with -UI metadata. - -| Package | Purpose | Docs | -| ------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -| `@modelcontextprotocol/ext-apps` | Build interactive Views (App class, PostMessageTransport) | [API Docs โ†’](https://apps.extensions.modelcontextprotocol.io/api/modules/app.html) | -| `@modelcontextprotocol/ext-apps/react` | React hooks for Views (useApp, useHostStyles, etc.) | [API Docs โ†’](https://apps.extensions.modelcontextprotocol.io/api/modules/_modelcontextprotocol_ext-apps_react.html) | -| `@modelcontextprotocol/ext-apps/app-bridge` | Embed and communicate with Views in your chat client | [API Docs โ†’](https://apps.extensions.modelcontextprotocol.io/api/modules/app-bridge.html) | -| `@modelcontextprotocol/ext-apps/server` | Register tools and resources on your MCP server | [API Docs โ†’](https://apps.extensions.modelcontextprotocol.io/api/modules/server-helpers.html) | - -There's no _supported_ host implementation in this repo (beyond the -[examples/basic-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) -example). - -The [MCP-UI](https://github.com/idosal/mcp-ui) client SDK offers a -fully-featured MCP Apps framework used by a few hosts. Clients may choose to use -it or roll their own implementation. +> The build tools (`esbuild`, `tsx`, `typescript`) are in `dependencies` rather than `devDependencies`. This is intentional: it allows the `prepare` script to run when the package is installed from git, since npm doesn't install devDependencies for git dependencies. +> +> Once the package is published to npm with pre-built `dist/`, these can be moved back to `devDependencies`. ## Examples -The -[`examples/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples) -directory contains demo apps showcasing real-world use cases. +- [`examples/basic-server-react`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react) โ€” Example MCP server with tools that return UI Apps (React) +- [`examples/basic-server-vanillajs`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs) โ€” Example MCP server with tools that return UI Apps (vanilla JS) +- [`examples/basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) โ€” Bare-bones example of hosting MCP Apps - -| | | | -|:---:|:---:|:---:| -| [![Map](examples/map-server/grid-cell.png "Interactive 3D globe viewer using CesiumJS")](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) | [![Three.js](examples/threejs-server/grid-cell.png "Interactive 3D scene renderer")](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/threejs-server) | [![ShaderToy](examples/shadertoy-server/grid-cell.png "Real-time GLSL shader renderer")](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/shadertoy-server) | -| [**Map**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) | [**Three.js**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/threejs-server) | [**ShaderToy**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/shadertoy-server) | -| [![Sheet Music](examples/sheet-music-server/grid-cell.png "ABC notation to sheet music")](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/sheet-music-server) | [![Wiki Explorer](examples/wiki-explorer-server/grid-cell.png "Wikipedia link graph visualization")](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/wiki-explorer-server) | [![Cohort Heatmap](examples/cohort-heatmap-server/grid-cell.png "Customer retention heatmap")](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/cohort-heatmap-server) | -| [**Sheet Music**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/sheet-music-server) | [**Wiki Explorer**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/wiki-explorer-server) | [**Cohort Heatmap**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/cohort-heatmap-server) | -| [![Scenario Modeler](examples/scenario-modeler-server/grid-cell.png "SaaS business projections")](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/scenario-modeler-server) | [![Budget Allocator](examples/budget-allocator-server/grid-cell.png "Interactive budget allocation")](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/budget-allocator-server) | [![Customer Segmentation](examples/customer-segmentation-server/grid-cell.png "Scatter chart with clustering")](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/customer-segmentation-server) | -| [**Scenario Modeler**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/scenario-modeler-server) | [**Budget Allocator**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/budget-allocator-server) | [**Customer Segmentation**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/customer-segmentation-server) | -| [![System Monitor](examples/system-monitor-server/grid-cell.png "Real-time OS metrics")](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/system-monitor-server) | [![Transcript](examples/transcript-server/grid-cell.png "Live speech transcription")](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/transcript-server) | [![Video Resource](examples/video-resource-server/grid-cell.png "Binary video via MCP resources")](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/video-resource-server) | -| [**System Monitor**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/system-monitor-server) | [**Transcript**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/transcript-server) | [**Video Resource**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/video-resource-server) | -| [![PDF Server](examples/pdf-server/grid-cell.png "Interactive PDF viewer with chunked loading")](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server) | [![QR Code](examples/qr-server/grid-cell.png "QR code generator")](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/qr-server) | [![Say Demo](examples/say-server/grid-cell.png "Text-to-speech demo")](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/say-server) | -| [**PDF Server**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server) | [**QR Code (Python)**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/qr-server) | [**Say Demo**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/say-server) | +To run the examples end-to-end: -### Starter Templates - -| | | -|:---:|:---| -| [![Basic](examples/basic-server-react/grid-cell.png "Starter template")](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react) | The same app built with different frameworks โ€” pick your favorite!

[React](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react) ยท [Vue](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vue) ยท [Svelte](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-svelte) ยท [Preact](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-preact) ยท [Solid](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-solid) ยท [Vanilla JS](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs) | - - -### Running the Examples - -#### With basic-host - -To run all examples locally using -[basic-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) -(the reference host implementation included in this repo): - -```bash -git clone https://github.com/modelcontextprotocol/ext-apps.git -cd ext-apps -npm install -npm start +``` +npm i +npm run examples:start ``` -Then open http://localhost:8080/. - -#### With MCP Clients - -Every Node.js example is published as `@modelcontextprotocol/server-`. To -add one to an MCP client that supports stdio (Claude Desktop, VS Code, etc.), -use this pattern: - -```json -{ - "mcpServers": { - "": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-", "--stdio"] - } - } -} -``` - -For example, to add the map server: `@modelcontextprotocol/server-map`. The -Python examples (`qr-server`, `say-server`) use `uv run` instead โ€” see their -READMEs for details. - -#### Local Development - -To test local modifications with an MCP client, clone the repo, install, then -point your client at a local build: - -```json -{ - "mcpServers": { - "": { - "command": "bash", - "args": [ - "-c", - "cd ~/code/ext-apps/examples/-server && npm run build >&2 && node dist/index.js --stdio" - ] - } - } -} -``` - -## Specification - -
- -| Version | Status | Link | -| -------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------- | -| **2026-01-26** | Stable | [specification/2026-01-26/apps.mdx](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx) | -| **draft** | Development | [specification/draft/apps.mdx](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx) | - -
+Then open http://localhost:8080/ ## Resources -- [Quickstart Guide](https://apps.extensions.modelcontextprotocol.io/api/documents/quickstart.html) -- [API Documentation](https://apps.extensions.modelcontextprotocol.io/api/) -- [Specification (2026-01-26)](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx) - ([Draft](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx)) +- [Quickstart Guide](https://modelcontextprotocol.github.io/ext-apps/api/documents/Quickstart.html) +- [API Documentation](https://modelcontextprotocol.github.io/ext-apps/api/) +- [Draft Specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx) - [SEP-1865 Discussion](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865) - -## Contributing - -Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for -guidelines on how to get started, submit pull requests, and report issues. diff --git a/RELEASES.md b/RELEASES.md deleted file mode 100644 index 7b80eaf..0000000 --- a/RELEASES.md +++ /dev/null @@ -1,64 +0,0 @@ -# Release Notes - -## 0.3.0 - -### Breaking Changes - -- **`viewport` replaced with `containerDimensions`** โ€” The `viewport` property in host context has been replaced with `containerDimensions`, which provides a clearer distinction between fixed dimensions and maximum constraints. The new type allows specifying either `height` or `maxHeight`, and either `width` or `maxWidth` by @martinalong in [#153](https://github.com/modelcontextprotocol/ext-apps/pull/153) - -### New Examples - -- **Video resource server** โ€” Demonstrates video resource handling with proper mimeType declarations by @antonpk1 in [#175](https://github.com/modelcontextprotocol/ext-apps/pull/175) -- **Sheet music server** โ€” Interactive sheet music notation example by @jonathanhefner in [#196](https://github.com/modelcontextprotocol/ext-apps/pull/196) - -### Developer Experience - -- **`npm start` alias** โ€” Added `npm start` as alias for `npm run examples:start` by @jonathanhefner in [#183](https://github.com/modelcontextprotocol/ext-apps/pull/183) -- **Examples cleanup** โ€” Improved consistency across example servers by @jonathanhefner in [#182](https://github.com/modelcontextprotocol/ext-apps/pull/182) -- **Documentation fixes** โ€” Fixed tsc command in docs to use tsconfig.json by @blackgirlbytes in [#188](https://github.com/modelcontextprotocol/ext-apps/pull/188) - -### Bug Fixes - -- **Move prettier to dev dependency** โ€” Fixed incorrect dependency classification by @niclim in [#179](https://github.com/modelcontextprotocol/ext-apps/pull/179) -- **Fix build errors in examples** โ€” Resolved build issues across example servers by @jonathanhefner in [#180](https://github.com/modelcontextprotocol/ext-apps/pull/180) - -**Full Changelog**: https://github.com/modelcontextprotocol/ext-apps/compare/v0.2.2...v0.3.0 - ---- - -## 0.2.2 - -Changes from 0.1.x to 0.2.2. - -### Highlights - -- **Server helpers** โ€” New `registerAppTool()` and `registerAppResource()` simplify server setup with proper type safety. `connect()` now defaults to `PostMessageTransport(window.parent)`, enabling simpler initialization with just `await app.connect()` by @ochafik in [#165](https://github.com/modelcontextprotocol/ext-apps/pull/165) -- **Tool visibility control** โ€” New `visibility` array field controls whether tools are visible to the agent, apps, or both. Restructured `_meta` format from flat `"ui/resourceUri"` to nested `_meta.ui.resourceUri` by @jonathanhefner in [#131](https://github.com/modelcontextprotocol/ext-apps/pull/131) -- **Host-provided theming** โ€” Apps receive 36 standardized CSS variables for colors, typography, and spacing via `styles.variables`, enabling visual consistency with the host by @martinalong in [#127](https://github.com/modelcontextprotocol/ext-apps/pull/127) -- **Display mode requests** โ€” Apps can request display mode changes (e.g., fullscreen) via `requestDisplayMode`, with hosts able to accept or reject by @martinalong in [#152](https://github.com/modelcontextprotocol/ext-apps/pull/152) -- **Custom fonts support** โ€” Apps can receive custom fonts via `styles.css.fonts` and apply them using `applyHostFonts()` or `useHostFonts()` helpers by @martinalong in [#159](https://github.com/modelcontextprotocol/ext-apps/pull/159) - -### API Changes - -- **MCP SDK as peer dependency** โ€” Consumers control their SDK version, reducing duplication by @ochafik in [#168](https://github.com/modelcontextprotocol/ext-apps/pull/168) -- **React as peer dependency** โ€” Supports React 17, 18, and 19 by @ochafik in [#164](https://github.com/modelcontextprotocol/ext-apps/pull/164) -- **Renamed request methods** โ€” Removed `send` prefix: `openLink()` and `teardownResource()`. Deprecated aliases maintained by @ochafik in [#161](https://github.com/modelcontextprotocol/ext-apps/pull/161) -- **Optional Client in AppBridge** โ€” Enables custom forwarding scenarios without direct MCP client access by @ochafik in [#146](https://github.com/modelcontextprotocol/ext-apps/pull/146) -- **Zod v3 and v4 support** โ€” Bring your own Zod by @alpic-ai in [#49](https://github.com/modelcontextprotocol/ext-apps/pull/49) -- **Zod schemas now version-agnostic** โ€” Generated schemas work with both Zod 3.25+ and v4, using `z.object().passthrough()` instead of v4-only `z.looseObject()` by @ochafik in [#178](https://github.com/modelcontextprotocol/ext-apps/pull/178) - -### Platform & DX - -- **Windows compatibility** โ€” Bun as optional dependency with automatic setup, `cross-env` for examples. Just run `npm install` by @ochafik in [#145](https://github.com/modelcontextprotocol/ext-apps/pull/145) -- **Widened `@oven/bun-*` version range** โ€” Lowered the minimum from `^1.3.4` to `^1.2.21`, helping contributors whose registries may not have the latest packages by @ochafik in [#176](https://github.com/modelcontextprotocol/ext-apps/pull/176) -- **SSE transport support** โ€” All examples now support both stdio and HTTP transports with SSE endpoints for older clients by @ochafik in [#136](https://github.com/modelcontextprotocol/ext-apps/pull/136) -- **Playwright E2E tests** โ€” Screenshot golden testing across all 9 example servers with parallel execution (~28s) by @ochafik in [#115](https://github.com/modelcontextprotocol/ext-apps/pull/115) -- **hostContext exposure** โ€” Access `hostContext` directly from the `App` class by @ochafik in [#139](https://github.com/modelcontextprotocol/ext-apps/pull/139) - -### Bug Fixes - -- **Responsive UIs** โ€” Fixed narrow viewport handling for mobile and sidebar experiences by @ochafik in [#135](https://github.com/modelcontextprotocol/ext-apps/pull/135) -- **Sandbox notification fix** โ€” Fixed `sandbox-ready` notification name to match implementation by @ochafik in [#160](https://github.com/modelcontextprotocol/ext-apps/pull/160) -- **Fixed non-UI tool registration** โ€” Use `server.registerTool` for tools without UI and fix missing imports across examples by @ochafik in [#173](https://github.com/modelcontextprotocol/ext-apps/pull/173) - -**Full Changelog**: https://github.com/modelcontextprotocol/ext-apps/compare/v0.1.1...v0.2.2 diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 5029242..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,21 +0,0 @@ -# Security Policy - -Thank you for helping keep the Model Context Protocol and its ecosystem secure. - -## Reporting Security Issues - -If you discover a security vulnerability in this repository, please report it through -the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) -for this repository. - -Please **do not** report security vulnerabilities through public GitHub issues, discussions, -or pull requests. - -## What to Include - -To help us triage and respond quickly, please include: - -- A description of the vulnerability -- Steps to reproduce the issue -- The potential impact -- Any suggested fixes (optional) diff --git a/build.bun.ts b/build.bun.ts index 61361e6..5e20e69 100644 --- a/build.bun.ts +++ b/build.bun.ts @@ -1,25 +1,13 @@ #!/usr/bin/env bun import { $ } from "bun"; -import { cpSync, mkdirSync, rmSync } from "node:fs"; - -// Avoid publishing artifacts left behind by an earlier build or branch. -rmSync("dist", { recursive: true, force: true }); // Run TypeScript compiler for type declarations await $`tsc`; -// Copy schema.json (tsc is emitDeclarationOnly, Bun.build doesn't emit JSON assets). -// Needed for the "./schema.json" package export. -mkdirSync("dist/src/generated", { recursive: true }); -cpSync("src/generated/schema.json", "dist/src/generated/schema.json"); - const isDevelopment = Bun.env.NODE_ENV === "development"; // Build all JavaScript/TypeScript files -function buildJs( - entrypoint: string, - opts: Partial[0]> = {}, -) { +function buildJs(entrypoint: string, opts: Record = {}) { return Bun.build({ entrypoints: [entrypoint], outdir: "dist", @@ -34,40 +22,14 @@ function buildJs( }); } -// Peer dependencies stay external in the standard entry points so consumers -// share one base MCP SDK and Zod instance. The *-with-deps entry points keep -// bundling these dependencies for standalone browser use. -const PEER_EXTERNALS = [ - "@modelcontextprotocol/client", - "@modelcontextprotocol/core", - "@modelcontextprotocol/server", - "zod", -]; - await Promise.all([ - buildJs("src/app.ts", { - outdir: "dist/src", - external: PEER_EXTERNALS, - }), - buildJs("src/app.ts", { - outdir: "dist/src", - naming: { entry: "app-with-deps.js" }, - }), + buildJs("src/app.ts", { outdir: "dist/src" }), buildJs("src/app-bridge.ts", { outdir: "dist/src", - external: PEER_EXTERNALS, - }), - buildJs("src/react/index.tsx", { - outdir: "dist/src/react", - external: ["react", "react-dom", ...PEER_EXTERNALS], + external: ["@modelcontextprotocol/sdk"], }), buildJs("src/react/index.tsx", { outdir: "dist/src/react", external: ["react", "react-dom"], - naming: { entry: "react-with-deps.js" }, - }), - buildJs("src/server/index.ts", { - outdir: "dist/src/server", - external: PEER_EXTERNALS, }), ]); diff --git a/docs/agent-skills.md b/docs/agent-skills.md deleted file mode 100644 index d27ad71..0000000 --- a/docs/agent-skills.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -title: Agent Skills -group: Getting Started -description: Use Agent Skills to build, migrate, and extend MCP Apps with AI coding agents. Install skills that scaffold new apps, convert OpenAI Apps, and add UI to existing servers. ---- - -# Agent Skills - -[Agent Skills](https://agentskills.io/) are instruction sets that guide AI coding agents through tasks. When you invoke a skill, the agent takes the lead โ€” it asks clarifying questions, makes decisions based on your codebase, and executes the work. - -This repository provides four skills: - -- [**create-mcp-app**](https://github.com/modelcontextprotocol/ext-apps/blob/main/plugins/mcp-apps/skills/create-mcp-app/SKILL.md) โ€” scaffolds a new MCP App with an interactive UI -- [**migrate-oai-app**](https://github.com/modelcontextprotocol/ext-apps/blob/main/plugins/mcp-apps/skills/migrate-oai-app/SKILL.md) โ€” migrates an existing OpenAI App to the MCP Apps SDK -- [**add-app-to-server**](https://github.com/modelcontextprotocol/ext-apps/blob/main/plugins/mcp-apps/skills/add-app-to-server/SKILL.md) โ€” adds interactive UI to an existing MCP server's tools -- [**convert-web-app**](https://github.com/modelcontextprotocol/ext-apps/blob/main/plugins/mcp-apps/skills/convert-web-app/SKILL.md) โ€” converts an existing web application into an MCP App - -## Install the Skills - -Choose one of the following installation methods based on your agent: - -### Option 1: Claude Code Plugin - -Install via Claude Code: - -``` -/plugin marketplace add modelcontextprotocol/ext-apps -/plugin install mcp-apps@modelcontextprotocol-ext-apps -``` - -### Option 2: Vercel Skills CLI - -Use the [Vercel Skills CLI](https://skills.sh/) to install skills across different AI coding agents: - -```bash -npx skills add modelcontextprotocol/ext-apps -``` - -### Option 3: Manual Installation - -Clone the repository: - -```bash -git clone https://github.com/modelcontextprotocol/ext-apps.git -``` - -Then copy the skills from `plugins/mcp-apps/skills/` to your agent's skills directory. See your agent's documentation for the correct location: - -- [Claude Code](https://docs.anthropic.com/en/docs/claude-code/skills) -- [VS Code](https://code.visualstudio.com/docs/copilot/customization/agent-skills) / [GitHub Copilot](https://docs.github.com/en/copilot/concepts/agents/about-agent-skills) -- [Codex](https://developers.openai.com/codex/skills/) -- [Gemini CLI](https://geminicli.com/docs/cli/skills/) -- [Cline](https://docs.cline.bot/features/skills#skills) -- [Goose](https://block.github.io/goose/docs/guides/context-engineering/using-skills/) - -## Verify Installation - -Ask your agent "What skills do you have?" โ€” you should see `create-mcp-app`, `migrate-oai-app`, `add-app-to-server`, and `convert-web-app` among the available skills. - -## Invoke a Skill - -Try invoking the skills by asking your agent: - -- "Create an MCP App" โ€” scaffolds a new MCP App with an interactive UI -- "Migrate from OpenAI Apps SDK" โ€” converts an existing OpenAI App to use the MCP Apps SDK -- "Add UI to my MCP server" โ€” adds interactive UI to an existing MCP server's tools -- "Convert my web app to an MCP App" โ€” converts an existing web application into an MCP App - -The agent will guide you through the process, asking clarifying questions as needed. - -## Test Your App - -After creating or migrating your MCP App, see the [Testing MCP Apps](./testing-mcp-apps.md) guide to run and debug it locally. diff --git a/docs/authorization.md b/docs/authorization.md deleted file mode 100644 index df7adc6..0000000 --- a/docs/authorization.md +++ /dev/null @@ -1,211 +0,0 @@ ---- -title: Authorization -group: Security -description: Learn how to protect MCP App tools with OAuth authorization, including per-server and per-tool auth patterns, token verification, and UI-initiated auth escalation. ---- - -# Authorization - -MCP Apps can protect tools behind OAuth-based authorization, as defined in the [MCP specification](https://modelcontextprotocol.io/specification/latest/basic/authorization). There are two approaches: - -- **Per-server authorization** โ€” The entire MCP server requires authorization at connection time. Every request must include a valid token, regardless of which tool is being called. This is the simpler model when all tools are sensitive. -- **Per-tool authorization** โ€” Only specific tools require authorization. Public tools work without a token, and the OAuth flow is triggered only when the user calls a protected tool. This lets you mix public and protected tools in the same server. - -## Shared setup - -Regardless of which approach you choose, you need OAuth discovery metadata and token verification. These are the same for both. - -### OAuth discovery metadata - -The MCP specification requires servers to implement [authorization server discovery](https://modelcontextprotocol.io/specification/latest/basic/authorization#authorization-server-discovery) so clients know how to obtain authorization. Two well-known endpoints are needed: - -**[Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728)** (`/.well-known/oauth-protected-resource`) โ€” describes the resource server and identifies which authorization server(s) can issue tokens for it. The MCP SDK's `mcpAuthRouter` handles this automatically. - -**Authorization Server Metadata** (`/.well-known/oauth-authorization-server`) โ€” advertises the authorization and token endpoints, supported scopes, and whether [Client ID Metadata Documents](https://modelcontextprotocol.io/specification/latest/basic/authorization#client-id-metadata-documents) (CIMD) is supported: - -```ts -app.get("/.well-known/oauth-authorization-server", (_req, res) => { - res.json({ - ...oauthMetadata, - client_id_metadata_document_supported: true, - }); -}); -``` - -Setting `client_id_metadata_document_supported: true` tells MCP clients to use CIMD instead of [Dynamic Client Registration](https://modelcontextprotocol.io/specification/latest/basic/authorization#dynamic-client-registration) (DCR). With CIMD, the `client_id` is a URL that serves the client's metadata document, removing the need for a registration endpoint. See [Client Registration Approaches](https://modelcontextprotocol.io/specification/latest/basic/authorization#client-registration-approaches) in the spec for the full list of options and priority order. - -### Token verification - -Verify access tokens as JWTs against the identity provider's JWKS endpoint. The `jose` library handles key fetching and caching: - -```ts -import { createRemoteJWKSet, jwtVerify } from "jose"; - -const JWKS = createRemoteJWKSet(new URL(`${IDP_DOMAIN}/.well-known/jwks.json`)); - -const { payload } = await jwtVerify(token, JWKS, { - issuer: IDP_DOMAIN, -}); -``` - -MCP servers must validate that tokens were issued specifically for them โ€” see [Token Handling](https://modelcontextprotocol.io/specification/latest/basic/authorization#token-handling) and [Access Token Privilege Restriction](https://modelcontextprotocol.io/specification/latest/basic/authorization#access-token-privilege-restriction) in the spec for the full requirements. - -## Per-server authorization - -With per-server authorization, every request to the `/mcp` endpoint must include a valid Bearer token. Any unauthorized request receives HTTP `401`, and the host must complete the OAuth flow before the client can use any tools. This is the right choice when all tools are sensitive and there's no value in allowing unauthorized access. - -The TypeScript MCP SDK supports this out of the box via `mcpAuthRouter` and `ProxyOAuthServerProvider` โ€” no custom HTTP handler logic is needed. See the [MCP SDK documentation](https://github.com/modelcontextprotocol/typescript-sdk) for setup details. - -## Per-tool authorization - -With per-tool authorization, the `/mcp` endpoint handler inspects the raw JSON-RPC request body, checks whether any message targets a protected tool, and only enforces authorization for those calls. Public tools pass through without a token. - -### How it works - -1. The server maintains a set of tool names that require authorization -2. When a JSON-RPC request arrives at the `/mcp` endpoint, the server inspects the request body to determine if any message is a `tools/call` targeting a protected tool -3. If a protected tool is being called and no valid Bearer token is present, the server returns HTTP `401` with a [`WWW-Authenticate` header](https://modelcontextprotocol.io/specification/latest/basic/authorization#protected-resource-metadata-discovery-requirements) pointing to its [Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728) -4. The MCP host (e.g., Claude Desktop) sees the `401`, discovers the authorization server via the metadata URL, runs the [OAuth flow](https://modelcontextprotocol.io/specification/latest/basic/authorization#authorization-flow-steps) with the user, and retries the request with the acquired token -5. On retry, the server verifies the token, extracts the user identity, and creates a per-request MCP server instance with that auth context -6. Unprotected tools pass through without any token check โ€” they work for everyone - -This design means authorization is enforced at the HTTP boundary (as [required by the spec](https://modelcontextprotocol.io/specification/latest/basic/authorization#access-token-usage)), not as a tool-level error. The MCP server itself never sees unauthorized requests for protected tools. - -### Enforcing HTTP 401 - -The [MCP auth specification](https://modelcontextprotocol.io/specification/latest/basic/authorization#access-token-usage) requires protected resources to return HTTP `401` responses โ€” not tool-level errors. - -Start by defining which tools require authorization. Then, in the `/mcp` endpoint handler, inspect the raw JSON-RPC request body, check whether any message targets a protected tool, and either verify the Bearer token or return `401` before the request ever reaches the MCP server: - -```ts -/** Tools that require a valid Bearer token โ€” checked at the HTTP level for proper 401. */ -const PROTECTED_TOOLS = new Set(["get_account_balance", "manage_branch_admin"]); - -app.all("/mcp", async (req, res) => { - // Parse the JSON-RPC body โ€” it may be a single message or a batch - const messages = Array.isArray(req.body) ? req.body : [req.body]; - - // Check if any message is a tools/call for a protected tool - const needsAuth = messages.some( - (msg: any) => - msg?.method === "tools/call" && PROTECTED_TOOLS.has(msg.params?.name), - ); - - // Extract and verify the Bearer token - let authInfo: AuthInfo | undefined; - const authHeader = req.headers.authorization; - - if (authHeader?.startsWith("Bearer ")) { - try { - const token = authHeader.slice(7); - const { payload } = await jwtVerify(token, JWKS, { - issuer: IDP_DOMAIN, - }); - authInfo = { token, sub: payload.sub as string }; - } catch { - if (needsAuth) { - return res - .status(401) - .set( - "WWW-Authenticate", - `Bearer resource_metadata="${resourceMetadataUrl}"`, - ) - .json({ - error: "invalid_token", - error_description: "The access token is invalid", - }); - } - } - } else if (needsAuth) { - return res - .status(401) - .set( - "WWW-Authenticate", - `Bearer resource_metadata="${resourceMetadataUrl}"`, - ) - .json({ - error: "invalid_token", - error_description: "Authorization required", - }); - } - - // Create a per-request MCP server with the auth context. - // authInfo is undefined for public tool calls, populated for - // authenticated requests โ€” tool handlers use it to scope data - // to the authenticated user. - const server = createServer(authInfo); - // ... handle the request with transport -}); -``` - -The `WWW-Authenticate` header includes the [Protected Resource Metadata](https://modelcontextprotocol.io/specification/latest/basic/authorization#authorization-server-location) URL, which tells the client where to discover the authorization server. - -### Defence-in-depth in tool handlers - -Even though the HTTP layer enforces authorization, protected tool handlers should also verify `authInfo` as a defence-in-depth measure. If the HTTP layer is misconfigured or bypassed, the tool handler catches unauthorized access: - -```ts -registerAppTool( - server, - "get_account_balance", - { - description: "Get account balance", - inputSchema: z.object({ accountId: z.string() }), - }, - async ({ accountId }) => { - if (!authInfo) { - return { - isError: true, - content: [ - { - type: "text", - text: "Authorization required to access account data.", - }, - ], - }; - } - - const balance = await getBalance(authInfo.sub, accountId); - return { - content: [{ type: "text", text: `Balance: ${balance}` }], - }; - }, -); -``` - -### UI-initiated auth escalation - -A powerful pattern is mixing public and protected tools in the same app. The app loads with public data (no authorization required), and the OAuth flow is triggered only when the user performs a protected action. This is a practical application of the [step-up authorization flow](https://modelcontextprotocol.io/specification/latest/basic/authorization#step-up-authorization-flow) described in the spec: - -1. A public tool (e.g., `manage_branch`) loads the UI without requiring authorization -2. The user clicks a button that calls a protected tool via `app.callServerTool()` -3. The MCP host receives HTTP `401` and automatically runs the OAuth flow -4. After the user completes the OAuth flow, the host retries the tool call with the acquired token -5. The protected data appears in the UI - -```tsx -function BranchItem({ branch }: { branch: Branch }) { - const [adminData, setAdminData] = useState(null); - - async function handleManage() { - // This call may trigger the OAuth flow if the user - // hasn't been authorized yet โ€” the host handles it - // transparently. - const result = await app.callServerTool({ - name: "manage_branch_admin", - arguments: { branch_id: branch.id }, - }); - setAdminData(result.structuredContent); - } - - return ( -
- {branch.name} - - {adminData && } -
- ); -} -``` - -This pattern keeps the initial experience fast (no login wall) while securing sensitive operations behind authorization. The host manages the entire OAuth flow โ€” the app code simply calls the tool and handles the result. diff --git a/docs/csp-cors.md b/docs/csp-cors.md deleted file mode 100644 index 8978ab6..0000000 --- a/docs/csp-cors.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: CSP and CORS -group: Security -description: Configure Content Security Policy and CORS for MCP Apps that make network requests from sandboxed iframes, including connectDomains, resourceDomains, and stable origin setup. ---- - -# CSP & CORS - -Unlike regular web apps, MCP Apps HTML is served as an MCP resource and runs in a sandboxed iframe with no same-origin server. Any app that makes network requests must configure Content Security Policy (CSP) and possibly CORS. - -**CSP** controls what the _browser_ allows. You must declare _all_ origins in {@link types!McpUiResourceMeta.csp `_meta.ui.csp`} ({@link types!McpUiResourceCsp `McpUiResourceCsp`}) โ€” including `localhost` during development. Declare `connectDomains` for fetch/XHR/WebSocket requests and `resourceDomains` for scripts, stylesheets, images, and fonts. - -**CORS** controls what the _API server_ allows. Public APIs that respond with `Access-Control-Allow-Origin: *` or use API key authentication work without CORS configuration. For APIs that allowlist specific origins, use {@link types!McpUiResourceMeta.domain `_meta.ui.domain`} to give the app a stable origin that the API server can allowlist. The format is host-specific, so check each host's documentation for its supported format. - - -```ts source="../src/server/index.examples.ts#registerAppResource_withDomain" -// Computes a stable origin from an MCP server URL for hosting in Claude. -function computeAppDomainForClaude(mcpServerUrl: string): string { - const hash = crypto - .createHash("sha256") - .update(mcpServerUrl) - .digest("hex") - .slice(0, 32); - return `${hash}.claudemcpcontent.com`; -} - -const APP_DOMAIN = computeAppDomainForClaude("https://example.com/mcp"); - -registerAppResource( - server, - "Company Dashboard", - "ui://dashboard/view.html", - { - description: "Internal dashboard with company data", - }, - async () => ({ - contents: [ - { - uri: "ui://dashboard/view.html", - mimeType: RESOURCE_MIME_TYPE, - text: dashboardHtml, - _meta: { - ui: { - // CSP: tell browser the app is allowed to make requests - csp: { - connectDomains: ["https://api.example.com"], - }, - // CORS: give app a stable origin for the API server to allowlist - // - // (Public APIs that use `Access-Control-Allow-Origin: *` or API - // key auth don't need this.) - domain: APP_DOMAIN, - }, - }, - }, - ], - }), -); -``` - -Note that `_meta.ui.csp` and `_meta.ui.domain` are set in the `contents[]` objects returned by the resource read callback, not in `registerAppResource()`'s config object. - -> [!NOTE] -> For full examples that configures CSP, see: [`examples/sheet-music-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/sheet-music-server) (`connectDomains`) and [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) (`connectDomains` and `resourceDomains`). diff --git a/docs/favicons/apple-touch-icon.png b/docs/favicons/apple-touch-icon.png deleted file mode 100644 index 2e10abc..0000000 Binary files a/docs/favicons/apple-touch-icon.png and /dev/null differ diff --git a/docs/favicons/favicon-16x16.png b/docs/favicons/favicon-16x16.png deleted file mode 100644 index 1548f7e..0000000 Binary files a/docs/favicons/favicon-16x16.png and /dev/null differ diff --git a/docs/favicons/favicon-32x32.png b/docs/favicons/favicon-32x32.png deleted file mode 100644 index 88f78f6..0000000 Binary files a/docs/favicons/favicon-32x32.png and /dev/null differ diff --git a/docs/favicons/favicon-dark-16x16.png b/docs/favicons/favicon-dark-16x16.png deleted file mode 100644 index 1548f7e..0000000 Binary files a/docs/favicons/favicon-dark-16x16.png and /dev/null differ diff --git a/docs/favicons/favicon-dark-32x32.png b/docs/favicons/favicon-dark-32x32.png deleted file mode 100644 index b861b12..0000000 Binary files a/docs/favicons/favicon-dark-32x32.png and /dev/null differ diff --git a/docs/favicons/favicon-dark.ico b/docs/favicons/favicon-dark.ico deleted file mode 100644 index fd1f24d..0000000 Binary files a/docs/favicons/favicon-dark.ico and /dev/null differ diff --git a/docs/favicons/favicon.ico b/docs/favicons/favicon.ico deleted file mode 100644 index b92e162..0000000 Binary files a/docs/favicons/favicon.ico and /dev/null differ diff --git a/docs/mcp-theme.css b/docs/mcp-theme.css deleted file mode 100644 index d786e55..0000000 --- a/docs/mcp-theme.css +++ /dev/null @@ -1,216 +0,0 @@ -/* - * MCP Apps custom theme โ€” aligns TypeDoc styling with modelcontextprotocol.io - */ - -@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"); - -/* ------------------------------------------------------------------ */ -/* Fonts */ -/* ------------------------------------------------------------------ */ - -:root { - --font-family-text: - "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --font-family-code: - "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; -} - -body { - font-family: var(--font-family-text); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -/* ------------------------------------------------------------------ */ -/* Light mode colors */ -/* ------------------------------------------------------------------ */ - -:root { - --light-color-background: #ffffff; - --light-color-background-secondary: #f9fafb; - --light-color-background-navbar: #ffffff; - --light-color-accent: #e5e7eb; - --light-color-text: #111827; - --light-color-text-aside: #6b7280; -} - -/* ------------------------------------------------------------------ */ -/* Dark mode colors */ -/* ------------------------------------------------------------------ */ - -:root { - --dark-color-background: #0f1117; - --dark-color-background-secondary: #161b22; - --dark-color-background-navbar: #0f1117; - --dark-color-accent: #30363d; - --dark-color-text: #f0f6fc; - --dark-color-text-aside: #8b949e; -} - -/* ------------------------------------------------------------------ */ -/* Header โ€” taller, frosted glass */ -/* ------------------------------------------------------------------ */ - -:root { - --dim-toolbar-contents-height: 3.5rem; -} - -.tsd-page-toolbar { - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); -} - -@media (prefers-color-scheme: light) { - .tsd-page-toolbar { - background-color: rgba(255, 255, 255, 0.85); - } -} - -@media (prefers-color-scheme: dark) { - .tsd-page-toolbar { - background-color: rgba(15, 17, 23, 0.75); - } -} - -:root[data-theme="light"] .tsd-page-toolbar { - background-color: rgba(255, 255, 255, 0.85); -} - -:root[data-theme="dark"] .tsd-page-toolbar { - background-color: rgba(15, 17, 23, 0.75); -} - -/* ------------------------------------------------------------------ */ -/* Theme-aware logo (rewritten from README by mcpstyle plugin) */ -/* ------------------------------------------------------------------ */ - -/* Explicit theme selection via TypeDoc switcher โ€” takes precedence */ -:root[data-theme="light"] .mcp-logo-dark, -:root[data-theme="dark"] .mcp-logo-light { - display: none; -} - -/* "OS" mode (data-theme='os') follows prefers-color-scheme */ -@media (prefers-color-scheme: light) { - :root[data-theme="os"] .mcp-logo-dark { - display: none; - } -} - -@media (prefers-color-scheme: dark) { - :root[data-theme="os"] .mcp-logo-light { - display: none; - } -} - -/* ------------------------------------------------------------------ */ -/* Sidebar navigation (matches modelcontextprotocol.io sidebar) */ -/* ------------------------------------------------------------------ */ - -.tsd-navigation a { - padding: 0.375rem 0.75rem; - font-size: 0.875rem; - line-height: 1.5; - border-radius: 0.75rem; - color: var(--color-text-aside); - transition: - background-color 0.15s ease, - color 0.15s ease; -} - -.tsd-navigation a:hover:not(.current) { - background-color: rgba(107, 114, 128, 0.05); - color: var(--color-text); -} - -.tsd-navigation a.current { - background-color: rgba(107, 114, 128, 0.15); - color: var(--color-text); - font-weight: 700; -} - -/* Group headers (Documents, Security, API Documentation) */ -.tsd-accordion-summary > h3 { - font-weight: 600; - font-size: 0.875rem; - letter-spacing: 0.01em; -} - -.site-menu { - padding: 1.25rem 0; -} - -@media (prefers-color-scheme: dark) { - .tsd-navigation a { - color: var(--color-text-aside); - } - - .tsd-navigation a:hover:not(.current) { - background-color: rgba(229, 231, 235, 0.05); - color: var(--color-text); - } - - .tsd-navigation a.current { - background-color: rgba(229, 231, 235, 0.15); - color: var(--color-text); - font-weight: 700; - } -} - -:root[data-theme="dark"] .tsd-navigation a { - color: var(--color-text-aside); -} - -:root[data-theme="dark"] .tsd-navigation a:hover:not(.current) { - background-color: rgba(229, 231, 235, 0.05); - color: var(--color-text); -} - -:root[data-theme="dark"] .tsd-navigation a.current { - background-color: rgba(229, 231, 235, 0.15); - color: var(--color-text); - font-weight: 700; -} - -/* ------------------------------------------------------------------ */ -/* Content area */ -/* ------------------------------------------------------------------ */ - -.tsd-typography { - line-height: 1.6; -} - -.tsd-panel.tsd-typography h1 { - margin-top: 0; - padding-bottom: 0.4em; -} - -.tsd-panel.tsd-typography h2 { - margin-top: 2rem; - padding-bottom: 0.3em; -} - -.tsd-panel.tsd-typography h3 { - margin-top: 1.75rem; -} - -/* ------------------------------------------------------------------ */ -/* Code blocks */ -/* ------------------------------------------------------------------ */ - -code, -pre { - font-family: var(--font-family-code); - font-size: 1em; -} - -.tsd-typography pre { - padding: 1rem 1.25rem; - border: 1px solid var(--color-accent); -} - -/* Inline code */ -.tsd-typography code:not(pre code) { - padding: 0.15em 0.35em; - font-size: 1em; -} diff --git a/docs/migrate-to-2.md b/docs/migrate-to-2.md deleted file mode 100644 index f9341d0..0000000 --- a/docs/migrate-to-2.md +++ /dev/null @@ -1,130 +0,0 @@ ---- -title: Migrate to v2 -group: Getting Started -description: Upgrade from ext-apps 1.x to 2.x โ€” new base MCP SDK peer packages, API changes for Views and hosts, and what stays wire-compatible. ---- - -# Migrating from ext-apps 1.x to 2.x - -ext-apps 2.x is built on the base MCP TypeScript SDK 2.x, which replaced the -single `@modelcontextprotocol/sdk` package with `@modelcontextprotocol/client`, -`server`, `core`, `node` and `express`. The MCP Apps wire protocol did not -change; the breaking changes are in dependencies and in the TypeScript API. - -## Host compatibility - -The `ui/*` messages exchanged over the iframe channel are byte-identical to -1.x. A 2.x View runs in a 1.x host and a 2.x host renders 1.x Views. The only -host-side deltas are in error responses (see below). - -## What stays the same - -`App` / `AppBridge` constructors, the `on*` setters, `addEventListener`, -`callServerTool`, `readServerResource`, `registerAppResource`, the React hooks -and the `_meta.ui.resourceUri` handling are unchanged. Published `.d.ts` files -now resolve under `moduleResolution: NodeNext` / `Node16` without workarounds -(#705). - -## Peer dependencies by role - -| Role | Install | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| View author | `@modelcontextprotocol/ext-apps`, `@modelcontextprotocol/client@^2.0.0`, `zod@^4.2.0` (+ `react`/`react-dom` for `./react`) | -| Host author | same as View author | -| MCP server author | View author packages + `@modelcontextprotocol/server@^2.0.0`; for HTTP transports also `@modelcontextprotocol/node@^2.0.0` (`NodeStreamableHTTPServerTransport`) and `@modelcontextprotocol/express@^2.0.0` (`createMcpExpressApp`, which peers on `express`) | -| CDN / `*-with-deps` | nothing extra: `./app-with-deps` and `./react-with-deps` bundle client, core and zod (about 25% larger than the 1.x bundles) | - -`@modelcontextprotocol/client` is a required peer (`App` and `AppBridge` extend -its `Protocol` class); `@modelcontextprotocol/core` is a required peer that `client` already depends on, so npm installs it without you listing it; -`@modelcontextprotocol/server` stays optional and is only needed for the -`./server` helpers. Node.js 20+ is required. - -## Breaking changes - -- **Peer packages.** `@modelcontextprotocol/sdk@^1` is replaced by the split - 2.x packages above, all at `^2.0.0`. Remove the 1.x package from your - project: the two SDKs share no classes or types (a 1.x `McpError` or - `Client` cannot be passed to 2.x code), even though they speak the same - wire protocol. -- **zod 3 dropped.** The peer range is `zod@^4.2.0`. Tool schemas must - implement Standard JSON Schema (`~standard.jsonSchema`): zod 4.2+, ArkType, - Valibot. zod 4.0 and 4.1 do not expose `~standard.jsonSchema` and are - rejected too. Raw zod shapes (`{ q: z.string() }`) still work with - `registerAppTool` but are deprecated; wrap them with `z.object({...})`. -- **`App` / `AppBridge` extend `Protocol` from `@modelcontextprotocol/client`.** - `ProtocolWithEvents` is gone; use the SDK's `Protocol`. The `AppRequest`, - `AppNotification` and `AppResult` unions are kept as deprecated type - aliases (nothing in 2.x consumes them) and will be removed in 3.0. -- **Handler context.** Custom request handlers, `registerAppTool` callbacks - (server) and `app.registerTool` callbacks (View) receive the SDK 2.x - context: `extra.signal` โ†’ `extra.mcpReq.signal`, `extra.requestId` โ†’ - `extra.mcpReq.id`; `extra.sessionId` is unchanged and `extra.authInfo` - moved to `extra.http?.authInfo`. -- **`setRequestHandler` / `setNotificationHandler` take method names.** - `app.setRequestHandler(SomeRequestSchema, handler)` becomes - `app.setRequestHandler("some/method", { params: SomeParamsSchema }, (params, ctx) => โ€ฆ)` - for custom methods (the handler receives the parsed params); the two-argument - `setRequestHandler("tools/call", handler)` form exists only for spec-defined - method names. The 1.x `(Schema, handler)` form still works on `App` and - `AppBridge` as a deprecated overload: it logs a one-time warning, hands the - handler the whole `{ method, params }` message as before, and gives request - handlers a 1.x-shaped `extra` (`signal`, `requestId`, `sessionId`, `_meta`, - `sendRequest`, `sendNotification`, `authInfo`; exported as - `LegacyRequestHandlerExtra`). It will be removed in 3.0. -- **Double-registration guard covers the SDK's own handlers.** Registering a - handler for `notifications/progress` or `notifications/cancelled` (which - the base `Protocol` already owns) now throws `already registered`, as - `ping` and the `on*`-owned methods did in 1.x. Use `addEventListener` or - the SDK's progress callbacks instead of replacing these. -- **Errors.** Remote JSON-RPC errors are `ProtocolError` (numeric `code`); - local conditions are `SdkError` with a string `code`: request timeout โ†’ - `"REQUEST_TIMEOUT"`, connection closed โ†’ `"CONNECTION_CLOSED"`. Cancelling a - request with an `AbortSignal` also rejects with `"REQUEST_TIMEOUT"` (the - message is the abort reason). Messages no longer carry the `MCP error N:` - prefix. - A resource-not-found reply that carries `data.uri` arrives as - `ResourceNotFoundError` (a `ProtocolError` subclass, `code` `-32602`, with a - `.uri` getter). - -## Host-side wire deltas - -Observed when a 2.x `AppBridge` answers a View; a 1.x View sees these too. - -| Situation | 1.x host | 2.x host | -| --------------------------------------------------------- | ------------------------------ | --------------------------------------------------- | -| A handler throws `-32002` (resource not found) | `error.code: -32002` | `error.code: -32602` (the SDK never emits `-32002`) | -| Invalid params on a `ui/*` request | `-32603` with a zod issue dump | `-32602 Invalid params for : โ€ฆ` | -| Error message text | `MCP error -32602: โ€ฆ` prefix | plain message | -| `tools/call` to an unknown tool through a 2.x `McpServer` | `result.isError: true` | JSON-RPC error `-32602` (`callServerTool` rejects) | - -## `schema.json` - -The published `./schema.json` export is regenerated from the 2.x core schemas: - -- `McpUiToolResultNotification.params.structuredContent` is any JSON value (was - `type: "object"`). -- `McpUiToolResultNotification.params._meta` documents - `io.modelcontextprotocol/serverInfo` and no longer lists `progressToken` / - `related-task` (both still pass through). -- `McpUiHostContext.toolInfo.tool.outputSchema` is a loose object (only - `$schema` is documented); `inputSchema.properties` values are now typed as - JSON values. -- A recursive JSON-value definition (`__schema0`) is added under the `$defs` of - `McpUiHostContext`, `McpUiHostContextChangedNotification` and - `McpUiInitializeResult`. - -## Checklist - -1. `npm uninstall @modelcontextprotocol/sdk` and install the packages for your - role from the table above. -2. Replace `sdk/...` imports with the split packages (`sdk/server/mcp.js` โ†’ - `@modelcontextprotocol/server`, `sdk/server/streamableHttp.js` โ†’ - `NodeStreamableHTTPServerTransport` from `@modelcontextprotocol/node`, - `sdk/server/stdio.js` โ†’ `@modelcontextprotocol/server/stdio`, hand-written - Express routing โ†’ `createMcpExpressApp` from `@modelcontextprotocol/express`, - `sdk/types.js` โ†’ `@modelcontextprotocol/client` or `server` for the types, - `@modelcontextprotocol/core` for the zod schemas). -3. Wrap raw zod shapes with `z.object({...})`. -4. Update custom handlers to the `extra.mcpReq.*` context and method-keyed - `setRequestHandler` calls. -5. Replace `McpError` / numeric-code checks with `ProtocolError` / `SdkError`. diff --git a/docs/migrate_from_openai_apps.md b/docs/migrate_from_openai_apps.md deleted file mode 100644 index 22d5d9f..0000000 --- a/docs/migrate_from_openai_apps.md +++ /dev/null @@ -1,393 +0,0 @@ ---- -title: Migrate OpenAI App -group: Getting Started -description: Migrate from the OpenAI Apps SDK to MCP Apps SDK with concept mapping tables, API equivalents, and complete before/after code examples. ---- - -# Migrating from OpenAI Apps SDK to MCP Apps SDK - -This reference maps OpenAI Apps SDK concepts to their MCP Apps SDK (`@modelcontextprotocol/ext-apps`) equivalents. Use the tables below for quick lookup during migration, and refer to the code examples for complete before/after comparisons. - -This guide covers server-side changes first (metadata, tools, resources), then client-side changes (setup, context, events). - -> [!NOTE] -> Some OpenAI Apps SDK features don't have MCP equivalents yet. These are marked "Not yet implemented" in the tables below. - -## Server-Side - -The server-side changes involve updating metadata structure and using helper functions. - -### Quick Start Comparison - -| OpenAI Apps SDK | MCP Apps SDK | -| ---------------------------------------------------------- | -------------------------------------------------------------- | -| Flat metadata keys (`_meta["openai/..."]`) | Nested metadata structure (`_meta.ui.*`) | -| Direct `server.registerTool()`/`server.registerResource()` | Helper functions: `registerAppTool()`, `registerAppResource()` | -| UI Resource MIME type: `text/html+skybridge` | UI Resource MIME type: `text/html;profile=mcp-app` | - -### Tool Metadata - -| OpenAI | MCP Apps | Notes | -| ---------------------------------------------- | ---------------------------------- | ----------------------------------------------------------- | -| `_meta["openai/outputTemplate"]` | `_meta.ui.resourceUri` | URI of UI resource | -| `_meta["openai/toolInvocation/invoking"]` | โ€” | Not yet implemented | -| `_meta["openai/toolInvocation/invoked"]` | โ€” | Not yet implemented | -| `_meta["openai/widgetAccessible"]` (`boolean`) | `_meta.ui.visibility` (`string[]`) | `true`/`false` โ†’ include/exclude `"app"` in array | -| `_meta["openai/visibility"]` (`string`) | `_meta.ui.visibility` (`string[]`) | `"public"`/`"private"` โ†’ include/exclude `"model"` in array | - -### Resource Metadata - -| OpenAI | MCP Apps | Notes | -| ------------------------------------- | ------------------------ | ----------------------------------------------------------------------- | -| `_meta["openai/widgetCSP"]` | `_meta.ui.csp` | See [CSP field mapping](#csp-field-mapping) below | -| โ€” | `_meta.ui.permissions` | MCP adds: permissions for camera, microphone, geolocation, clipboard | -| `_meta["openai/widgetDomain"]` | `_meta.ui.domain` | Dedicated sandbox origin | -| `_meta["openai/widgetPrefersBorder"]` | `_meta.ui.prefersBorder` | Visual boundary preference | -| `_meta["openai/widgetDescription"]` | โ€” | Not yet implemented; use `app.updateModelContext()` for dynamic context | - -### Resource MIME Type - -| OpenAI | MCP Apps | Notes | -| --------------------- | --------------------------- | -------------------------------------------------------------------------------- | -| `text/html+skybridge` | `text/html;profile=mcp-app` | Auto-set by `registerAppResource()`; use `RESOURCE_MIME_TYPE` constant if manual | - -### CSP Field Mapping - -| OpenAI | MCP Apps | Notes | -| ------------------ | ----------------- | ---------------------------------------------------------- | -| `resource_domains` | `resourceDomains` | Origins for static assets (images, fonts, styles, scripts) | -| `connect_domains` | `connectDomains` | Origins for fetch/XHR/WebSocket requests | -| `frame_domains` | `frameDomains` | Origins for nested iframes | -| `redirect_domains` | โ€” | OpenAI-only: origins for `openExternal` redirects | -| โ€” | `baseUriDomains` | MCP-only: `base-uri` CSP directive | - -### Server-Side Migration Example - -#### Before (OpenAI) - -```typescript -import { McpServer } from "@modelcontextprotocol/server"; -import { z } from "zod"; - -function createServer() { - const server = new McpServer({ name: "shop", version: "1.0.0" }); - - // Register tool with OpenAI metadata - server.registerTool( - "shopping-cart", - { - title: "Shopping Cart", - description: "Display the user's shopping cart", - inputSchema: z.object({ userId: z.string() }), - annotations: { readOnlyHint: true }, - _meta: { - "openai/outputTemplate": "ui://view/cart.html", - "openai/toolInvocation/invoking": "Loading cart...", - "openai/toolInvocation/invoked": "Cart ready", - "openai/widgetAccessible": true, - }, - }, - async (args) => { - const cart = await getCart(args.userId); - return { - content: [{ type: "text", text: JSON.stringify(cart) }], - structuredContent: { cart }, - }; - }, - ); - - // Register UI resource - server.registerResource( - "Cart View", - "ui://view/cart.html", - { mimeType: "text/html+skybridge" }, - async () => ({ - contents: [ - { - uri: "ui://view/cart.html", - mimeType: "text/html+skybridge", - text: getCartHtml(), - _meta: { - "openai/widgetCSP": { - resource_domains: ["https://cdn.example.com"], - connect_domains: ["https://api.example.com"], - frame_domains: ["https://embed.example.com"], - }, - }, - }, - ], - }), - ); - - return server; -} -``` - -#### After (MCP Apps) - -```typescript -import { McpServer } from "@modelcontextprotocol/server"; -import { - registerAppTool, - registerAppResource, - RESOURCE_MIME_TYPE, -} from "@modelcontextprotocol/ext-apps/server"; -import { z } from "zod"; - -function createServer() { - const server = new McpServer({ name: "shop", version: "1.0.0" }); - - // Register tool with MCP Apps metadata - registerAppTool( - server, - "shopping-cart", - { - title: "Shopping Cart", - description: "Display the user's shopping cart", - inputSchema: z.object({ userId: z.string() }), - annotations: { readOnlyHint: true }, - _meta: { ui: { resourceUri: "ui://view/cart.html" } }, - }, - async (args) => { - const cart = await getCart(args.userId); - return { - content: [{ type: "text", text: JSON.stringify(cart) }], - structuredContent: { cart }, - }; - }, - ); - - // Register UI resource - registerAppResource( - server, - "Cart View", - "ui://view/cart.html", - { description: "Shopping cart UI" }, - async () => ({ - contents: [ - { - uri: "ui://view/cart.html", - mimeType: RESOURCE_MIME_TYPE, - text: getCartHtml(), - _meta: { - ui: { - csp: { - resourceDomains: ["https://cdn.example.com"], - connectDomains: ["https://api.example.com"], - frameDomains: ["https://embed.example.com"], - }, - }, - }, - }, - ], - }), - ); - - return server; -} -``` - -### Key Differences Summary - -1. **Metadata Structure**: OpenAI uses flat `_meta["openai/..."]` properties; MCP uses nested `_meta.ui.*` structure -2. **Tool Visibility**: OpenAI uses boolean/string (`true`/`"public"`); MCP uses string arrays (`["app", "model"]`) -3. **CSP Field Names**: snake_case โ†’ camelCase (e.g., `connect_domains` โ†’ `connectDomains`) -4. **App Permissions**: MCP adds `_meta.ui.permissions` for camera, microphone, geolocation, clipboard (not in OpenAI) -5. **Resource MIME Type**: `text/html+skybridge` โ†’ `text/html;profile=mcp-app` (use `RESOURCE_MIME_TYPE` constant) -6. **Helper Functions**: MCP provides `registerAppTool()` and `registerAppResource()` helpers -7. **Not Yet Implemented**: `_meta["openai/toolInvocation/invoking"]`, `_meta["openai/toolInvocation/invoked"]`, and `_meta["openai/widgetDescription"]` don't have MCP equivalents yet - -## Client-Side - -Client-side migration involves replacing the implicit `window.openai` global with an explicit `App` instance. - -### Quick Start Comparison - -| OpenAI Apps SDK | MCP Apps SDK | -| --------------------------------- | ---------------------------------- | -| Implicit global (`window.openai`) | Explicit instance (`new App(...)`) | -| Properties pre-populated on load | Async connection + notifications | -| Sync property access | Getters + event handlers | - -### Setup & Connection - -| OpenAI | MCP Apps | Notes | -| -------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| `window.openai` (auto-available) | `const app = new App({name, version})` | MCP requires explicit instantiation | -| (implicit) | Vanilla: `await app.connect()` / React: `useApp()` | MCP requires async connection; auto-detects OpenAI env | -| โ€” | `await app.connect(new OpenAITransport())` | Force OpenAI mode (not yet available, see [PR #172](https://github.com/modelcontextprotocol/ext-apps/pull/172)) | -| โ€” | `await app.connect(new PostMessageTransport(...))` | Force MCP mode explicitly | - -### Host Context Properties - -| OpenAI | MCP Apps | Notes | -| --------------------------- | --------------------------------------------- | --------------------------------------- | -| `window.openai.theme` | `app.getHostContext()?.theme` | `"light"` \| `"dark"` | -| `window.openai.locale` | `app.getHostContext()?.locale` | BCP 47 language tag (e.g., `"en-US"`) | -| `window.openai.displayMode` | `app.getHostContext()?.displayMode` | `"inline"` \| `"pip"` \| `"fullscreen"` | -| `window.openai.maxHeight` | `app.getHostContext()?.viewport?.maxHeight` | Max container height in px | -| `window.openai.safeArea` | `app.getHostContext()?.safeAreaInsets` | `{ top, right, bottom, left }` | -| `window.openai.userAgent` | `app.getHostContext()?.userAgent` | Host user agent string | -| โ€” | `app.getHostContext()?.availableDisplayModes` | MCP adds: which modes host supports | -| โ€” | `app.getHostContext()?.toolInfo` | MCP adds: tool metadata during call | - -### Tool Data (Input/Output) - -| OpenAI | MCP Apps | Notes | -| ------------------------------------ | ------------------------------------------------------------- | ----------------------------------- | -| `window.openai.toolInput` | `app.ontoolinput = (params) => { params.arguments }` | Tool arguments; MCP uses callback | -| `window.openai.toolOutput` | `app.ontoolresult = (params) => { params.structuredContent }` | Tool result; MCP uses callback | -| `window.openai.toolResponseMetadata` | `app.ontoolresult` โ†’ `params._meta` | Widget-only metadata from server | -| โ€” | `app.ontoolinputpartial = (params) => {...}` | MCP adds: streaming partial args | -| โ€” | `app.ontoolcancelled = (params) => {...}` | MCP adds: cancellation notification | - -### Calling Tools - -| OpenAI | MCP Apps | Notes | -| ------------------------------------------ | ----------------------------------------------------- | ---------------------------- | -| `await window.openai.callTool(name, args)` | `await app.callServerTool({ name, arguments: args })` | Call another MCP server tool | - -### Sending Messages - -| OpenAI | MCP Apps | Notes | -| ----------------------------------------------------- | ------------------------------------------------------------------------------------ | --------------------------------- | -| `await window.openai.sendFollowUpMessage({ prompt })` | `await app.sendMessage({ role: "user", content: [{ type: "text", text: prompt }] })` | MCP uses structured content array | - -### External Links - -| OpenAI | MCP Apps | Notes | -| -------------------------------------------- | ----------------------------------- | ------------------------------------ | -| `await window.openai.openExternal({ href })` | `await app.openLink({ url: href })` | Different param name: `href` โ†’ `url` | - -### Display Mode - -| OpenAI | MCP Apps | Notes | -| -------------------------------------------------- | --------------------------------------------------------- | ----------------------------------- | -| `await window.openai.requestDisplayMode({ mode })` | `await app.requestDisplayMode({ mode })` | Same API | -| โ€” | Check `app.getHostContext()?.availableDisplayModes` first | MCP lets you check what's available | - -### Size Reporting - -| OpenAI | MCP Apps | Notes | -| --------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------- | -| `window.openai.notifyIntrinsicHeight(height)` | `app.sendSizeChanged({ width, height })` | MCP includes width | -| Manual only | `new App(appInfo, capabilities, { autoResize: true /* default */ })` | MCP auto-reports via `ResizeObserver` | - -### State Persistence - -| OpenAI | MCP Apps | Notes | -| ------------------------------------- | -------- | -------------------------------------------------------------------- | -| `window.openai.widgetState` | โ€” | Not directly available in MCP | -| `window.openai.setWidgetState(state)` | โ€” | Use alternative mechanisms (`localStorage`, server-side state, etc.) | - -### File Operations (Not Yet in MCP Apps) - -| OpenAI | MCP Apps | Notes | -| ---------------------------------------------------- | -------- | ------------------- | -| `await window.openai.uploadFile(file)` | โ€” | Not yet implemented | -| `await window.openai.getFileDownloadUrl({ fileId })` | โ€” | Not yet implemented | - -### Other (Not Yet in MCP Apps) - -| OpenAI | MCP Apps | Notes | -| ------------------------------------------- | -------- | ------------------- | -| `await window.openai.requestModal(options)` | โ€” | Not yet implemented | -| `window.openai.requestClose()` | โ€” | Not yet implemented | -| `window.openai.setOpenInAppUrl({ href })` | โ€” | Not yet implemented | -| `window.openai.view` | โ€” | Not yet mapped | - -### Event Handling - -| OpenAI | MCP Apps | Notes | -| --------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------ | -| `window.openai.toolInput` (read on load) | `app.ontoolinput = (params) => {...}` | OpenAI: sync property; MCP: callback (register before `connect()`) | -| `window.openai.toolOutput` (read on load) | `app.ontoolresult = (params) => {...}` | OpenAI: sync property; MCP: callback (register before `connect()`) | -| `addEventListener("openai:set_globals", ...)` | `app.onhostcontextchanged = (ctx) => {...}` | Both push updates; MCP uses callback, OpenAI uses DOM event | -| โ€” | `app.onteardown = async () => {...}` | MCP adds: cleanup before unmount | - -### Logging - -| OpenAI | MCP Apps | Notes | -| ------------------ | --------------------------------------------- | ------------------------------- | -| `console.log(...)` | `app.sendLog({ level: "info", data: "..." })` | MCP provides structured logging | - -### Host Info - -| OpenAI | MCP Apps | Notes | -| ------ | --------------------------- | ------------------------------------------------- | -| โ€” | `app.getHostVersion()` | Returns `{ name, version }` of host | -| โ€” | `app.getHostCapabilities()` | Check `serverTools`, `openLinks`, `logging`, etc. | - -### Client-Side Migration Example - -#### Before (OpenAI) - -```typescript -// OpenAI Apps SDK -applyTheme(window.openai.theme); -console.log("Tool args:", window.openai.toolInput); -console.log("Tool result:", window.openai.toolOutput); - -// Call a tool -const result = await window.openai.callTool("get_weather", { city: "Tokyo" }); - -// Send a message -await window.openai.sendFollowUpMessage({ prompt: "Weather updated!" }); - -// Report height -window.openai.notifyIntrinsicHeight(400); - -// Open link -await window.openai.openExternal({ href: "https://example.com" }); -``` - -#### After (MCP Apps) - -```typescript -import { App } from "@modelcontextprotocol/ext-apps"; - -const app = new App({ name: "MyApp", version: "1.0.0" }); - -// Register handlers BEFORE connect (events may occur immediately after connect) -app.ontoolinput = (params) => { - console.log("Tool args:", params.arguments); -}; - -app.ontoolresult = (params) => { - console.log("Tool result:", params.structuredContent); -}; - -app.onhostcontextchanged = (ctx) => { - if (ctx.theme) applyTheme(ctx.theme); -}; - -// Connect (auto-detects OpenAI vs MCP) -await app.connect(); - -// Access context -applyTheme(app.getHostContext()?.theme); - -// Call a tool -const result = await app.callServerTool({ - name: "get_weather", - arguments: { city: "Tokyo" }, -}); - -// Send a message -await app.sendMessage({ - role: "user", - content: [{ type: "text", text: "Weather updated!" }], -}); - -// Open link (note: url not href) -await app.openLink({ url: "https://example.com" }); -``` - -### Key Differences Summary - -1. **Initialization**: OpenAI is implicit; MCP requires `new App()` + `await app.connect()` -2. **Data Flow**: OpenAI pre-populates; MCP uses async notifications (register handlers before `connect()`) -3. **Auto-resize**: MCP has built-in ResizeObserver support via `autoResize` option -4. **Structured Content**: MCP uses `{ type: "text", text: "..." }` arrays for messages -5. **Context Changes**: MCP pushes updates via `onhostcontextchanged`; no polling needed -6. **Capabilities**: MCP lets you check what the host supports before calling methods diff --git a/docs/overview.md b/docs/overview.md deleted file mode 100644 index 8eda95d..0000000 --- a/docs/overview.md +++ /dev/null @@ -1,217 +0,0 @@ ---- -title: Overview -group: Getting Started -description: MCP Apps extends the Model Context Protocol to let MCP servers deliver interactive UIs โ€” charts, forms, dashboards โ€” rendered securely in iframes inside any compliant host. ---- - -# MCP Apps Overview - -MCP Apps is an extension to the Model Context Protocol that enables MCP servers to deliver interactive user interfaces to hosts. It defines how servers declare UI resources, how hosts render them securely in iframes, and how the two communicate. - -## Why MCP Apps? - -MCP allows servers to expose tools and resources to AI assistants, but responses are limited to text and structured data. Many use cases need more: - -- **Data visualization** โ€” Charts, graphs, and dashboards that update as data changes -- **Rich media** โ€” Video players, audio waveforms, 3D models -- **Interactive forms** โ€” Multi-step wizards, configuration panels, approval workflows -- **Real-time displays** โ€” Live logs, progress indicators, streaming content - -Before MCP Apps, each host implemented UI support differently. MCP-UI, OpenAI's Apps SDK, and custom implementations all solved similar problems with incompatible approaches. Server developers had to maintain separate adapters for each host, and security models varied. - -MCP Apps standardizes this. Servers declare their UIs once; any compliant host can render them. Security is consistent โ€” sandboxed iframes, auditable communication, declarative CSP. The result is write-once portability with predictable behavior. - -## Progressive Enhancement - -MCP Apps is designed for graceful degradation. Hosts advertise their UI support when connecting to servers; servers check these capabilities before registering UI-enabled tools. If a host doesn't support MCP Apps, tools still work โ€” they just return text instead of UI. - -This is fundamental: UI is a progressive enhancement, not a requirement. Your server works everywhere; hosts that support UI get a richer experience. - -See the [Client<>Server Capability Negotiation](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#clientserver-capability-negotiation) section of the specification. - -## Architecture - -In MCP Apps, three entities work together: - -```mermaid -flowchart LR - subgraph Server ["MCP Server"] - T[Tools] - R[UI Resources] - end - - subgraph Host ["Host (Chat Client)"] - B[AppBridge] - end - - subgraph View ["View (iframe)"] - A[App] - end - - Server <-->|MCP Protocol| Host - Host <-->|postMessage| View -``` - -- **Server** โ€” A standard MCP server that declares tools and UI resources. The server defines what the UI looks like (HTML) and what tools it exposes. -- **Host** โ€” The chat client (e.g., Claude Desktop) that connects to servers, embeds Views in iframes, and proxies communication between them. -- **View** โ€” The UI running inside a sandboxed iframe. It receives tool data from the Host and can call server tools or send messages back to the chat. - -The View's `App` and the Host's `AppBridge` both subclass the `Protocol` class -exported by `@modelcontextprotocol/client` for the iframe channel. A separate outer `Client` connects -the Host to the actual MCP Server. Keeping those two connections separate -preserves the Apps-only iframe handshake and prevents iframe capabilities from -leaking into the server connection. - -## Lifecycle - -```mermaid -sequenceDiagram - participant H as Host - participant V as View (iframe) - participant S as MCP Server - - Note over H,S: 1. Discovery - S-->>H: tools/list (with UI metadata) - - Note over H,V: 2. Initialization - H->>H: Render iframe with UI resource - V->>H: ui/initialize - H-->>V: host context, capabilities - V->>H: ui/notifications/initialized - - Note over H,V: 3. Data Delivery - H-->>V: ui/notifications/tool-input - H-->>V: ui/notifications/tool-result - - Note over H,V: 4. Interactive Phase - loop User Interaction - V->>H: tools/call - H->>S: tools/call - S-->>H: result - H-->>V: result - end - - Note over H,V: 5. Teardown - H->>V: ui/resource-teardown - V-->>H: acknowledgment -``` - -1. **Discovery** โ€” The Host learns about tools and their UI resources when connecting to the server. -2. **Initialization** โ€” When a UI tool is called, the Host renders the iframe. The View sends `ui/initialize` (the only handshake on the iframe channel) and receives host context (theme, capabilities, container dimensions). `ui/initialize` is authoritative for Apps capabilities and identity, and `ui/notifications/initialized` is the public View-ready signal. -3. **Data delivery** โ€” The Host sends tool arguments and, once available, tool results to the View. Results include both `content` (text for the model's context) and optionally `structuredContent` (data optimized for UI rendering). This separation lets servers provide rich data to the UI without bloating the model's context. -4. **Interactive phase** โ€” The user interacts with the View. The View can call tools, send messages, or update context. -5. **Teardown** โ€” Before unmounting, the Host notifies the View so it can save state or release resources. - -See the [Lifecycle](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#lifecycle) section of the specification for the complete sequence diagrams. - -## UI Resources - -UI resources are HTML templates that servers declare using the `ui://` URI scheme. When a tool with UI metadata is called, the Host fetches the corresponding resource and renders it. - -Resources are declared upfront, during tool registration. This design enables: - -- **Prefetching** โ€” Hosts can cache templates before tool execution -- **Separation of concerns** โ€” Templates (presentation) are separate from tool results (data) -- **Review** โ€” Hosts can inspect UI templates during connection setup - -See the [UI Resource Format](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#ui-resource-format) section of the specification for the full schema. - -## Tool-UI Linkage - -Tools reference their UI templates through metadata. When a server registers a tool, it includes a `_meta.ui` object pointing to a `ui://` resource: - -```json -"_meta": { - "ui": { "resourceUri": "ui://weather/forecast" } -} -``` - -When this tool is called, the Host: - -1. Fetches the UI resource -2. Renders it in a sandboxed iframe -3. Passes the tool arguments and results to the View - -See the [Resource Discovery](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#resource-discovery) section of the specification for details. - -## Bidirectional Communication - -Views communicate with Hosts using JSON-RPC over [`postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage). From a View, you can: - -**Interact with the server:** - -- Call server tools (`tools/call`) -- Read server resources (`resources/read`) - -**Interact with the chat:** - -- Send messages to the conversation (`ui/message`) -- Update model context (`ui/update-model-context`) - -**Request host actions:** - -- Open external links (`ui/open-link`) - -See the {@link app!App} class for the View-side API and the [Communication Protocol](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#communication-protocol) section of the specification. - -## Tool Visibility - -Tools can be visible to the model, the app, or both. By default, tools are visible to both (`visibility: ["model", "app"]`). - -App-only tools (`visibility: ["app"]`) are useful for UI interactions that shouldn't clutter the agent's context โ€” things like refresh buttons, pagination controls, or form submissions. The model never sees these tools; they exist purely for the View to call. - -## Host Context - -When a View initializes, the Host provides context about the environment it's running in. This includes: - -- **Theme** โ€” Light or dark mode preference -- **Locale and timezone** โ€” For formatting dates, numbers, and text -- **Display mode** โ€” Whether the View is inline, fullscreen, or picture-in-picture -- **Container dimensions** โ€” The available space, which may be fixed by the Host or flexible based on content -- **Platform** โ€” Web, desktop, or mobile - -Views use this context to adapt their presentation. For example, a chart might use dark colors when the host is in dark mode, or a form might adjust its layout for mobile platforms. - -The Host notifies Views when context changes (e.g., the user toggles dark mode), allowing dynamic updates without reloading. - -See the [Host Context](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#host-context-in-mcpuiinitializeresult) section of the specification for the full schema. - -## Theming - -Hosts provide CSS custom properties for colors, typography, and borders. Views use these variables with fallbacks to match the host's visual style: - -```css -.container { - background: var(--color-background-primary, #ffffff); - color: var(--color-text-primary, #000000); -} -``` - -Theme changes (light/dark toggle) are sent via notifications, allowing Views to update dynamically. - -See the [Theming](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#theming) section of the specification for the full list of CSS variables. - -## Display Modes - -Views can be displayed in different modes: - -- **inline** โ€” Embedded in the chat flow. Best for content that's part of the conversation (charts, previews, forms). -- **fullscreen** โ€” Takes over the window. Use for immersive experiences like editors, games, or complex dashboards. -- **pip** โ€” Picture-in-picture overlay. Useful for persistent widgets (music player, timer) that should remain visible while the user continues chatting. - -Views declare which modes they support; Hosts declare which they can provide. Views can request mode changes, but the Host decides whether to honor them โ€” the Host always has final say over its own UI. - -See the [Display Modes](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#display-modes) section of the specification. - -## Security - -All Views run in sandboxed iframes with no access to the Host's DOM, cookies, or storage. Communication happens only through [`postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage), making it auditable. - -Servers declare which network domains their UI needs via [CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) metadata. Hosts enforce these declarations โ€” if no domains are declared, no external connections are allowed. This "restrictive by default" approach prevents data exfiltration to undeclared servers. - -See the [Security Implications](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#security-implications) section of the specification for the threat model and mitigations. - -## Learn More - -- [Specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx) โ€” Full protocol specification -- [Quickstart](./quickstart.md) โ€” Build your first MCP App diff --git a/docs/patterns.md b/docs/patterns.md deleted file mode 100644 index 884de26..0000000 --- a/docs/patterns.md +++ /dev/null @@ -1,627 +0,0 @@ ---- -title: Patterns -group: Getting Started -description: Common patterns and recipes for building MCP Apps โ€” polling, chunked data, binary resources, theming, fullscreen, model context, state persistence, and more. ---- - -# MCP Apps Patterns - -This document covers common patterns and recipes for building MCP Apps. - -## Tools that are private to Apps - -Set {@link types!McpUiToolMeta.visibility `Tool._meta.ui.visibility`} to `["app"]` to make tools only callable by Apps (hidden from the model). This is useful for UI-driven actions like updating server-side state, polling, or other interactions that shouldn't appear in the model's tool list. - - -```ts source="../src/server/index.examples.ts#registerAppTool_appOnlyVisibility" -registerAppTool( - server, - "update-quantity", - { - description: "Update item quantity in cart", - inputSchema: z.object({ itemId: z.string(), quantity: z.number() }), - _meta: { - ui: { - resourceUri: "ui://shop/cart.html", - visibility: ["app"], - }, - }, - }, - async ({ itemId, quantity }) => { - const cart = await updateCartItem(itemId, quantity); - return { content: [{ type: "text", text: JSON.stringify(cart) }] }; - }, -); -``` - -> [!NOTE] -> For full examples that implement this pattern, see: [`examples/system-monitor-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/system-monitor-server) and [`examples/pdf-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server). - -## Polling for live data - -For real-time dashboards or monitoring views, use an app-only tool (with `visibility: ["app"]`) that the App polls at regular intervals. - -**Vanilla JS:** - - -```ts source="./patterns.tsx#pollingVanillaJs" -let intervalId: number | null = null; - -async function poll() { - const result = await app.callServerTool({ - name: "poll-data", - arguments: {}, - }); - updateUI(result.structuredContent); -} - -function startPolling() { - if (intervalId !== null) return; - poll(); - intervalId = window.setInterval(poll, 2000); -} - -function stopPolling() { - if (intervalId === null) return; - clearInterval(intervalId); - intervalId = null; -} - -// Clean up when host tears down the view -app.onteardown = async () => { - stopPolling(); - return {}; -}; -``` - -**React:** - - -```tsx source="./patterns.tsx#pollingReact" -useEffect(() => { - if (!app) return; - let cancelled = false; - - async function poll() { - const result = await app!.callServerTool({ - name: "poll-data", - arguments: {}, - }); - if (!cancelled) setData(result.structuredContent); - } - - poll(); - const id = setInterval(poll, 2000); - return () => { - cancelled = true; - clearInterval(id); - }; -}, [app]); -``` - -> [!NOTE] -> For a full example that implements this pattern, see: [`examples/system-monitor-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/system-monitor-server). - -## Reading large amounts of data via chunked tool calls - -Some host platforms have size limits on tool call responses, so large files (PDFs, images, etc.) cannot be sent in a single response. Use an app-only tool with chunked responses to bypass these limits while keeping the data out of model context. - -**Server-side**: Register an app-only tool that returns data in chunks with pagination metadata: - - -```tsx source="./patterns.tsx#chunkedDataServer" -// Define the chunk response schema -const DataChunkSchema = z.object({ - bytes: z.string(), // base64-encoded data - offset: z.number(), - byteCount: z.number(), - totalBytes: z.number(), - hasMore: z.boolean(), -}); - -const MAX_CHUNK_BYTES = 500 * 1024; // 500KB per chunk - -registerAppTool( - server, - "read_data_bytes", - { - title: "Read Data Bytes", - description: "Load binary data in chunks", - inputSchema: z.object({ - id: z.string().describe("Resource identifier"), - offset: z.number().min(0).default(0).describe("Byte offset"), - byteCount: z - .number() - .default(MAX_CHUNK_BYTES) - .describe("Bytes to read"), - }), - outputSchema: DataChunkSchema, - // Hidden from model - only callable by the App - _meta: { ui: { visibility: ["app"] } }, - }, - async ({ id, offset, byteCount }): Promise => { - const data = await loadData(id); // Your data loading logic - const chunk = data.slice(offset, offset + byteCount); - - return { - content: [{ type: "text", text: `${chunk.length} bytes at ${offset}` }], - structuredContent: { - bytes: Buffer.from(chunk).toString("base64"), - offset, - byteCount: chunk.length, - totalBytes: data.length, - hasMore: offset + chunk.length < data.length, - }, - }; - }, -); -``` - -**Client-side**: Loop calling the tool until all chunks are received: - - -```tsx source="./patterns.tsx#chunkedDataClient" -interface DataChunk { - bytes: string; // base64 - offset: number; - byteCount: number; - totalBytes: number; - hasMore: boolean; -} - -async function loadDataInChunks( - id: string, - onProgress?: (loaded: number, total: number) => void, -): Promise { - const CHUNK_SIZE = 500 * 1024; // 500KB chunks - const chunks: Uint8Array[] = []; - let offset = 0; - let totalBytes = 0; - let hasMore = true; - - while (hasMore) { - const result = await app.callServerTool({ - name: "read_data_bytes", - arguments: { id, offset, byteCount: CHUNK_SIZE }, - }); - - if (result.isError || !result.structuredContent) { - throw new Error("Failed to load data chunk"); - } - - const chunk = result.structuredContent as unknown as DataChunk; - totalBytes = chunk.totalBytes; - hasMore = chunk.hasMore; - - // Decode base64 to bytes - const binaryString = atob(chunk.bytes); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - chunks.push(bytes); - - offset += chunk.byteCount; - onProgress?.(offset, totalBytes); - } - - // Combine all chunks into single array - const fullData = new Uint8Array(totalBytes); - let pos = 0; - for (const chunk of chunks) { - fullData.set(chunk, pos); - pos += chunk.length; - } - - return fullData; -} - -// Usage: load data with progress updates -loadDataInChunks(resourceId, (loaded, total) => { - console.log(`Loading: ${Math.round((loaded / total) * 100)}%`); -}).then((data) => { - console.log(`Loaded ${data.length} bytes`); -}); -``` - -> [!NOTE] -> For a full example that implements this pattern, see: [`examples/pdf-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server). - -## Giving errors back to the model - -**Server-side**: Tool handler validates inputs and returns `{ isError: true, content: [...] }`. The model receives this error through the normal tool call response. - -**Client-side**: If a runtime error occurs (e.g., API failure, permission denied, resource unavailable), use {@link app!App.updateModelContext `updateModelContext`} to inform the model: - - -```ts source="../src/app.examples.ts#App_updateModelContext_reportError" -try { - const _stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - // ... use _stream for transcription -} catch (err) { - // Inform the model that the app is in a degraded state - await app.updateModelContext({ - content: [ - { - type: "text", - text: "Error: transcription unavailable", - }, - ], - }); -} -``` - -## Serving binary blobs via resources - -Binary content (e.g., video) can be served via MCP resources as base64-encoded blobs. The server returns the data in the `blob` field of the resource content, and the App fetches it via `resources/read` for use in the browser. - -**Server-side**: Register a resource that returns binary data in the `blob` field: - - -```ts source="./patterns.tsx#binaryBlobResourceServer" -server.registerResource( - "Video", - new ResourceTemplate("video://{id}", { list: undefined }), - { - description: "Video data served as base64 blob", - mimeType: "video/mp4", - }, - async (uri, { id }): Promise => { - // Fetch or load your binary data - const idString = Array.isArray(id) ? id[0] : id; - const buffer = await getVideoData(idString); - const blob = Buffer.from(buffer).toString("base64"); - - return { contents: [{ uri: uri.href, mimeType: "video/mp4", blob }] }; - }, -); -``` - -**Client-side**: Fetch the resource and convert the base64 blob to a data URI: - - -```ts source="./patterns.tsx#binaryBlobResourceClient" -const result = await app.readServerResource({ uri: `video://${videoId}` }); - -const content = result.contents[0]; -if (!content || !("blob" in content)) { - throw new Error("Resource did not contain blob data"); -} - -const videoEl = document.querySelector("video")!; -videoEl.src = `data:${content.mimeType!};base64,${content.blob}`; -``` - -> [!NOTE] -> For a full example that implements this pattern, see: [`examples/video-resource-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/video-resource-server). - -## Configuring CSP and CORS - -See the dedicated [CSP & CORS](./csp-cors.md) guide in the Security section. - -## Adapting to host context (theme, styling, fonts, and safe areas) - -The host provides context about its environment via {@link types!McpUiHostContext `McpUiHostContext`}. Use this to adapt your app's appearance and layout: - -- **Theme** โ€” Use `[data-theme="dark"]` selectors or `light-dark()` function for theme-aware styles -- **CSS variables** โ€” Use `var(--color-background-primary)`, etc. in your CSS (see {@link types!McpUiStyleVariableKey `McpUiStyleVariableKey`} for a full list) -- **Fonts** โ€” Use `var(--font-sans)` or `var(--font-mono)` with fallbacks (e.g., `font-family: var(--font-sans, system-ui, sans-serif)`) -- **Safe area insets** โ€” Apply padding to avoid device notches, rounded corners, or system UI overlays - -**Vanilla JS:** - - -```tsx source="./patterns.tsx#hostContextVanillaJs" -function applyHostContext(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`; - } -} - -// Apply when host context changes -app.onhostcontextchanged = applyHostContext; - -// Apply initial context after connecting -app.connect().then(() => { - const ctx = app.getHostContext(); - if (ctx) { - applyHostContext(ctx); - } -}); -``` - -**React:** - - -```tsx source="./patterns.tsx#hostContextReact" -function MyApp() { - const [hostContext, setHostContext] = useState(); - - const { app } = useApp({ - appInfo: { name: "MyApp", version: "1.0.0" }, - capabilities: {}, - onAppCreated: (app) => { - app.onhostcontextchanged = (ctx) => { - setHostContext((prev) => ({ ...prev, ...ctx })); - }; - }, - }); - - // Set initial host context after connection - useEffect(() => { - if (app) { - setHostContext(app.getHostContext()); - } - }, [app]); - - // Apply styles when host context changes - useEffect(() => { - if (hostContext?.theme) { - applyDocumentTheme(hostContext.theme); - } - if (hostContext?.styles?.variables) { - applyHostStyleVariables(hostContext.styles.variables); - } - if (hostContext?.styles?.css?.fonts) { - applyHostFonts(hostContext.styles.css.fonts); - } - }, [hostContext]); - - return ( -
- Styled with host CSS variables, fonts, and safe area insets -
- ); -} -``` - -> [!NOTE] -> For full examples that implement this pattern, see: [`examples/basic-server-vanillajs/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs) and [`examples/basic-server-react/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react). - -## Entering / exiting fullscreen - -Toggle fullscreen mode by calling {@link app!App.requestDisplayMode `requestDisplayMode`}: - - -```ts source="../src/app.examples.ts#App_requestDisplayMode_toggle" -const container = document.getElementById("main")!; -const ctx = app.getHostContext(); -const newMode = ctx?.displayMode === "inline" ? "fullscreen" : "inline"; -if (ctx?.availableDisplayModes?.includes(newMode)) { - const result = await app.requestDisplayMode({ mode: newMode }); - container.classList.toggle("fullscreen", result.mode === "fullscreen"); -} -``` - -Listen for display mode changes via {@link app!App.onhostcontextchanged `onhostcontextchanged`} to update your UI: - - -```ts source="../src/app.examples.ts#App_onhostcontextchanged_respondToDisplayMode" -app.onhostcontextchanged = (ctx) => { - // Adjust to current display mode - if (ctx.displayMode) { - const container = document.getElementById("main")!; - const isFullscreen = ctx.displayMode === "fullscreen"; - container.classList.toggle("fullscreen", isFullscreen); - } - - // Adjust display mode controls - if (ctx.availableDisplayModes) { - const fullscreenBtn = document.getElementById("fullscreen-btn")!; - const canFullscreen = ctx.availableDisplayModes.includes("fullscreen"); - fullscreenBtn.style.display = canFullscreen ? "block" : "none"; - } -}; -``` - -In fullscreen mode, remove the container's border radius so content extends to the viewport edges: - -```css -#main { - border-radius: var(--border-radius-lg); - - &.fullscreen { - border-radius: 0; - } -} -``` - -> [!NOTE] -> For full examples that implement this pattern, see: [`examples/shadertoy-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/shadertoy-server), [`examples/pdf-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server), and [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server). - -## Passing contextual information from the App to the model - -Use {@link app!App.updateModelContext `updateModelContext`} to keep the model informed about what the user is viewing or interacting with. Structure the content with YAML frontmatter for easy parsing: - - -```ts source="../src/app.examples.ts#App_updateModelContext_appState" -const markdown = `--- -item-count: ${itemList.length} -total-cost: ${totalCost} -currency: ${currency} ---- - -User is viewing their shopping cart with ${itemList.length} items selected: - -${itemList.map((item) => `- ${item}`).join("\n")}`; - -await app.updateModelContext({ - content: [{ type: "text", text: markdown }], -}); -``` - -> [!NOTE] -> For full examples that implement this pattern, see: [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) and [`examples/transcript-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/transcript-server). - -## Sending large follow-up messages - -When you need to send more data than fits in a message, use {@link app!App.updateModelContext `updateModelContext`} to set the context first, then {@link app!App.sendMessage `sendMessage`} with a brief prompt to trigger a response: - - -```ts source="../src/app.examples.ts#App_sendMessage_withLargeContext" -const markdown = `--- -word-count: ${fullTranscript.split(/\s+/).length} -speaker-names: ${speakerNames.join(", ")} ---- - -${fullTranscript}`; - -// Offload long transcript to model context -await app.updateModelContext({ content: [{ type: "text", text: markdown }] }); - -// Send brief trigger message -await app.sendMessage({ - role: "user", - content: [{ type: "text", text: "Summarize the key points" }], -}); -``` - -> [!NOTE] -> For a full example that implements this pattern, see: [`examples/transcript-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/transcript-server). - -## Persisting view state - -For recoverable view state (e.g., current page in a PDF viewer, camera position in a map), use [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) with a stable identifier provided by the server. - -**Server-side**: Tool handler generates a unique `viewUUID` and returns it in `CallToolResult._meta.viewUUID`: - - -```tsx source="./patterns.tsx#persistDataServer" -// In your tool callback, include viewUUID in the result metadata. -return { - content: [{ type: "text", text: `Displaying PDF viewer for "${title}"` }], - structuredContent: { url, title, pageCount, initialPage: 1 }, - _meta: { - viewUUID: randomUUID(), - }, -}; -``` - -**Client-side**: Receive the UUID in {@link app!App.ontoolresult `ontoolresult`} and use it as the storage key: - - -```tsx source="./patterns.tsx#persistData" -// Store the viewUUID received from the server -let viewUUID: string | undefined; - -// Helper to save state to localStorage -function saveState(state: T): void { - if (!viewUUID) return; - try { - localStorage.setItem(viewUUID, JSON.stringify(state)); - } catch (err) { - console.error("Failed to save view state:", err); - } -} - -// Helper to load state from localStorage -function loadState(): T | null { - if (!viewUUID) return null; - try { - const saved = localStorage.getItem(viewUUID); - return saved ? (JSON.parse(saved) as T) : null; - } catch (err) { - console.error("Failed to load view state:", err); - return null; - } -} - -// Receive viewUUID from the tool result -app.ontoolresult = (result) => { - viewUUID = result._meta?.viewUUID - ? String(result._meta.viewUUID) - : undefined; - - // Restore any previously saved state - const savedState = loadState<{ currentPage: number }>(); - if (savedState) { - // Apply restored state to your UI... - } -}; - -// Call saveState() whenever your view state changes -// e.g., saveState({ currentPage: 5 }); -``` - -For state that represents user effort (e.g., saved bookmarks, annotations, custom configurations), consider persisting it server-side using [app-only tools](#tools-that-are-private-to-apps) instead. Pass the `viewUUID` to the app-only tool to scope the saved data to that view instance. - -> [!NOTE] -> For full examples using `localStorage`, see: [`examples/pdf-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server) (persists current page) and [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) (persists camera position). - -## Pausing computation-heavy views when offscreen - -Views with animations, WebGL rendering, or polling can consume significant CPU/GPU even when scrolled offscreen. Use [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) to pause expensive operations when the view isn't visible: - - -```tsx source="./patterns.tsx#visibilityBasedPause" -// Use IntersectionObserver to pause when view scrolls out of view -const observer = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - animation.play(); // or startPolling(), etc - } else { - animation.pause(); // or stopPolling(), etc - } - }); -}); -observer.observe(container); - -// Clean up when the host tears down the view -app.onteardown = async () => { - observer.disconnect(); - animation.pause(); - return {}; -}; -``` - -> [!NOTE] -> For full examples that implement this pattern, see: [`examples/shadertoy-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/shadertoy-server) and [`examples/threejs-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/threejs-server). - -## Lowering perceived latency - -Use {@link app!App.ontoolinputpartial `ontoolinputpartial`} to receive streaming tool arguments as they arrive. This lets you show a loading preview before the complete input is available, such as streaming code into a `
` tag before executing it, partially rendering a table as data arrives, or incrementally populating a chart.
-
-
-```ts source="../src/app.examples.ts#App_ontoolinputpartial_progressiveRendering"
-const codePreview = document.querySelector("#code-preview")!;
-const canvas = document.querySelector("#canvas")!;
-
-app.ontoolinputpartial = (params) => {
-  codePreview.textContent = (params.arguments?.code as string) ?? "";
-  codePreview.style.display = "block";
-  canvas.style.display = "none";
-};
-
-app.ontoolinput = (params) => {
-  codePreview.style.display = "none";
-  canvas.style.display = "block";
-  render(params.arguments?.code as string);
-};
-```
-
-> [!IMPORTANT]
-> Partial arguments are "healed" JSON โ€” the host closes unclosed brackets/braces to produce valid JSON. This means objects may be incomplete (e.g., the last item in an array may be truncated). Don't rely on partial data for critical operations; use it only for preview UI.
-
-> [!NOTE]
-> For full examples that implement this pattern, see: [`examples/shadertoy-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/shadertoy-server) and [`examples/threejs-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/threejs-server).
diff --git a/docs/patterns.tsx b/docs/patterns.tsx
deleted file mode 100644
index bb9eaa4..0000000
--- a/docs/patterns.tsx
+++ /dev/null
@@ -1,462 +0,0 @@
-/**
- * Type-checked code examples for the patterns documentation.
- *
- * These examples are included in {@link ./patterns.md} via `@includeCode` tags.
- * Each function's region markers define the code snippet that appears in the docs.
- *
- * @module
- */
-
-import { App } from "../src/app.js";
-import {
-  applyDocumentTheme,
-  applyHostFonts,
-  applyHostStyleVariables,
-} from "../src/styles.js";
-import { randomUUID } from "node:crypto";
-import type {
-  CallToolResult,
-  ReadResourceResult,
-} from "@modelcontextprotocol/server";
-import type { McpUiHostContext } from "../src/types.js";
-import { useEffect, useState } from "react";
-import { useApp } from "../src/react/index.js";
-import { registerAppTool } from "../src/server/index.js";
-import { McpServer, ResourceTemplate } from "@modelcontextprotocol/server";
-import { z } from "zod";
-
-/**
- * Example: Polling for live data (Vanilla JS)
- */
-function pollingVanillaJs(app: App, updateUI: (data: unknown) => void) {
-  //#region pollingVanillaJs
-  let intervalId: number | null = null;
-
-  async function poll() {
-    const result = await app.callServerTool({
-      name: "poll-data",
-      arguments: {},
-    });
-    updateUI(result.structuredContent);
-  }
-
-  function startPolling() {
-    if (intervalId !== null) return;
-    poll();
-    intervalId = window.setInterval(poll, 2000);
-  }
-
-  function stopPolling() {
-    if (intervalId === null) return;
-    clearInterval(intervalId);
-    intervalId = null;
-  }
-
-  // Clean up when host tears down the view
-  app.onteardown = async () => {
-    stopPolling();
-    return {};
-  };
-  //#endregion pollingVanillaJs
-}
-
-/**
- * Example: Polling for live data (React)
- */
-function pollingReact(
-  app: App | null, // via useApp()
-) {
-  const [data, setData] = useState();
-
-  //#region pollingReact
-  useEffect(() => {
-    if (!app) return;
-    let cancelled = false;
-
-    async function poll() {
-      const result = await app!.callServerTool({
-        name: "poll-data",
-        arguments: {},
-      });
-      if (!cancelled) setData(result.structuredContent);
-    }
-
-    poll();
-    const id = setInterval(poll, 2000);
-    return () => {
-      cancelled = true;
-      clearInterval(id);
-    };
-  }, [app]);
-  //#endregion pollingReact
-}
-
-/**
- * Example: Server-side chunked data tool (app-only)
- */
-function chunkedDataServer(server: McpServer) {
-  //#region chunkedDataServer
-  // Define the chunk response schema
-  const DataChunkSchema = z.object({
-    bytes: z.string(), // base64-encoded data
-    offset: z.number(),
-    byteCount: z.number(),
-    totalBytes: z.number(),
-    hasMore: z.boolean(),
-  });
-
-  const MAX_CHUNK_BYTES = 500 * 1024; // 500KB per chunk
-
-  registerAppTool(
-    server,
-    "read_data_bytes",
-    {
-      title: "Read Data Bytes",
-      description: "Load binary data in chunks",
-      inputSchema: z.object({
-        id: z.string().describe("Resource identifier"),
-        offset: z.number().min(0).default(0).describe("Byte offset"),
-        byteCount: z
-          .number()
-          .default(MAX_CHUNK_BYTES)
-          .describe("Bytes to read"),
-      }),
-      outputSchema: DataChunkSchema,
-      // Hidden from model - only callable by the App
-      _meta: { ui: { visibility: ["app"] } },
-    },
-    async ({ id, offset, byteCount }): Promise => {
-      const data = await loadData(id); // Your data loading logic
-      const chunk = data.slice(offset, offset + byteCount);
-
-      return {
-        content: [{ type: "text", text: `${chunk.length} bytes at ${offset}` }],
-        structuredContent: {
-          bytes: Buffer.from(chunk).toString("base64"),
-          offset,
-          byteCount: chunk.length,
-          totalBytes: data.length,
-          hasMore: offset + chunk.length < data.length,
-        },
-      };
-    },
-  );
-  //#endregion chunkedDataServer
-}
-
-// Stub for the example
-declare function loadData(id: string): Promise;
-
-/**
- * Example: Client-side chunked data loading
- */
-function chunkedDataClient(app: App, resourceId: string) {
-  //#region chunkedDataClient
-  interface DataChunk {
-    bytes: string; // base64
-    offset: number;
-    byteCount: number;
-    totalBytes: number;
-    hasMore: boolean;
-  }
-
-  async function loadDataInChunks(
-    id: string,
-    onProgress?: (loaded: number, total: number) => void,
-  ): Promise {
-    const CHUNK_SIZE = 500 * 1024; // 500KB chunks
-    const chunks: Uint8Array[] = [];
-    let offset = 0;
-    let totalBytes = 0;
-    let hasMore = true;
-
-    while (hasMore) {
-      const result = await app.callServerTool({
-        name: "read_data_bytes",
-        arguments: { id, offset, byteCount: CHUNK_SIZE },
-      });
-
-      if (result.isError || !result.structuredContent) {
-        throw new Error("Failed to load data chunk");
-      }
-
-      const chunk = result.structuredContent as unknown as DataChunk;
-      totalBytes = chunk.totalBytes;
-      hasMore = chunk.hasMore;
-
-      // Decode base64 to bytes
-      const binaryString = atob(chunk.bytes);
-      const bytes = new Uint8Array(binaryString.length);
-      for (let i = 0; i < binaryString.length; i++) {
-        bytes[i] = binaryString.charCodeAt(i);
-      }
-      chunks.push(bytes);
-
-      offset += chunk.byteCount;
-      onProgress?.(offset, totalBytes);
-    }
-
-    // Combine all chunks into single array
-    const fullData = new Uint8Array(totalBytes);
-    let pos = 0;
-    for (const chunk of chunks) {
-      fullData.set(chunk, pos);
-      pos += chunk.length;
-    }
-
-    return fullData;
-  }
-
-  // Usage: load data with progress updates
-  loadDataInChunks(resourceId, (loaded, total) => {
-    console.log(`Loading: ${Math.round((loaded / total) * 100)}%`);
-  }).then((data) => {
-    console.log(`Loaded ${data.length} bytes`);
-  });
-  //#endregion chunkedDataClient
-}
-
-/**
- * Example: Serving binary blobs via resources (server-side)
- */
-function binaryBlobResourceServer(
-  server: McpServer,
-  getVideoData: (id: string) => Promise,
-) {
-  //#region binaryBlobResourceServer
-  server.registerResource(
-    "Video",
-    new ResourceTemplate("video://{id}", { list: undefined }),
-    {
-      description: "Video data served as base64 blob",
-      mimeType: "video/mp4",
-    },
-    async (uri, { id }): Promise => {
-      // Fetch or load your binary data
-      const idString = Array.isArray(id) ? id[0] : id;
-      const buffer = await getVideoData(idString);
-      const blob = Buffer.from(buffer).toString("base64");
-
-      return { contents: [{ uri: uri.href, mimeType: "video/mp4", blob }] };
-    },
-  );
-  //#endregion binaryBlobResourceServer
-}
-
-/**
- * Example: Serving binary blobs via resources (client-side)
- */
-async function binaryBlobResourceClient(app: App, videoId: string) {
-  //#region binaryBlobResourceClient
-  const result = await app.readServerResource({ uri: `video://${videoId}` });
-
-  const content = result.contents[0];
-  if (!content || !("blob" in content)) {
-    throw new Error("Resource did not contain blob data");
-  }
-
-  const videoEl = document.querySelector("video")!;
-  videoEl.src = `data:${content.mimeType!};base64,${content.blob}`;
-  //#endregion binaryBlobResourceClient
-}
-
-/**
- * Example: Adapting to host context (theme, CSS variables, fonts, safe areas)
- */
-function hostContextVanillaJs(app: App, mainEl: HTMLElement) {
-  //#region hostContextVanillaJs
-  function applyHostContext(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`;
-    }
-  }
-
-  // Apply when host context changes
-  app.onhostcontextchanged = applyHostContext;
-
-  // Apply initial context after connecting
-  app.connect().then(() => {
-    const ctx = app.getHostContext();
-    if (ctx) {
-      applyHostContext(ctx);
-    }
-  });
-  //#endregion hostContextVanillaJs
-}
-
-/**
- * Example: Adapting to host context with React (CSS variables, theme, fonts, safe areas)
- */
-function hostContextReact() {
-  //#region hostContextReact
-  function MyApp() {
-    const [hostContext, setHostContext] = useState();
-
-    const { app } = useApp({
-      appInfo: { name: "MyApp", version: "1.0.0" },
-      capabilities: {},
-      onAppCreated: (app) => {
-        app.onhostcontextchanged = (ctx) => {
-          setHostContext((prev) => ({ ...prev, ...ctx }));
-        };
-      },
-    });
-
-    // Set initial host context after connection
-    useEffect(() => {
-      if (app) {
-        setHostContext(app.getHostContext());
-      }
-    }, [app]);
-
-    // Apply styles when host context changes
-    useEffect(() => {
-      if (hostContext?.theme) {
-        applyDocumentTheme(hostContext.theme);
-      }
-      if (hostContext?.styles?.variables) {
-        applyHostStyleVariables(hostContext.styles.variables);
-      }
-      if (hostContext?.styles?.css?.fonts) {
-        applyHostFonts(hostContext.styles.css.fonts);
-      }
-    }, [hostContext]);
-
-    return (
-      
- Styled with host CSS variables, fonts, and safe area insets -
- ); - } - //#endregion hostContextReact -} - -/** - * Example: Persisting view state (server-side) - */ -function persistViewStateServer(url: string, title: string, pageCount: number) { - function toolCallback(): CallToolResult { - //#region persistDataServer - // In your tool callback, include viewUUID in the result metadata. - return { - content: [{ type: "text", text: `Displaying PDF viewer for "${title}"` }], - structuredContent: { url, title, pageCount, initialPage: 1 }, - _meta: { - viewUUID: randomUUID(), - }, - }; - //#endregion persistDataServer - } -} - -/** - * Example: Persisting view state (client-side) - */ -function persistViewState(app: App) { - //#region persistData - // Store the viewUUID received from the server - let viewUUID: string | undefined; - - // Helper to save state to localStorage - function saveState(state: T): void { - if (!viewUUID) return; - try { - localStorage.setItem(viewUUID, JSON.stringify(state)); - } catch (err) { - console.error("Failed to save view state:", err); - } - } - - // Helper to load state from localStorage - function loadState(): T | null { - if (!viewUUID) return null; - try { - const saved = localStorage.getItem(viewUUID); - return saved ? (JSON.parse(saved) as T) : null; - } catch (err) { - console.error("Failed to load view state:", err); - return null; - } - } - - // Receive viewUUID from the tool result - app.ontoolresult = (result) => { - viewUUID = result._meta?.viewUUID - ? String(result._meta.viewUUID) - : undefined; - - // Restore any previously saved state - const savedState = loadState<{ currentPage: number }>(); - if (savedState) { - // Apply restored state to your UI... - } - }; - - // Call saveState() whenever your view state changes - // e.g., saveState({ currentPage: 5 }); - //#endregion persistData -} - -/** - * Example: Pausing computation-heavy views when out of view - */ -function visibilityBasedPause( - app: App, - container: HTMLElement, - animation: { play: () => void; pause: () => void }, -) { - //#region visibilityBasedPause - // Use IntersectionObserver to pause when view scrolls out of view - const observer = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - animation.play(); // or startPolling(), etc - } else { - animation.pause(); // or stopPolling(), etc - } - }); - }); - observer.observe(container); - - // Clean up when the host tears down the view - app.onteardown = async () => { - observer.disconnect(); - animation.pause(); - return {}; - }; - //#endregion visibilityBasedPause -} - -// Suppress unused variable warnings -void pollingVanillaJs; -void pollingReact; -void chunkedDataServer; -void chunkedDataClient; -void binaryBlobResourceServer; -void binaryBlobResourceClient; -void hostContextVanillaJs; -void hostContextReact; -void persistViewStateServer; -void persistViewState; -void visibilityBasedPause; diff --git a/docs/quickstart-success.png b/docs/quickstart-success.png deleted file mode 100644 index 058fb3f..0000000 Binary files a/docs/quickstart-success.png and /dev/null differ diff --git a/docs/quickstart.md b/docs/quickstart.md index a021912..9fdda05 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,369 +1,174 @@ --- title: Quickstart -group: Getting Started -description: Build your first MCP App step by step โ€” create an MCP server with an interactive View that renders inside Claude Desktop and other MCP hosts. --- # Build Your First MCP App -This tutorial walks you through building an MCP Appโ€”a tool with an interactive **View** (a UI that renders inside an iframe) that displays in MCP hosts like Claude Desktop. - -> [!TIP] -> Feel like vibe coding instead? Try the [MCP Apps agent skills](./agent-skills.md). +This tutorial walks you through building an MCP Appโ€”a tool with an interactive UI that renders inside MCP hosts like Claude Desktop. ## What You'll Build -A simple app that fetches the current server time and displays it in an interactive View. You'll learn the core pattern: **MCP Apps = Tool + UI Resource**. The complete example is available in [`examples/quickstart`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/quickstart). +A simple app that fetches the current server time and displays it in a clickable UI. You'll learn the core pattern: **MCP Apps = Tool + UI Resource**. ## Prerequisites -This tutorial assumes you've built an MCP server before and are comfortable with [Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools) and [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources). If not, the [official MCP quickstart](https://modelcontextprotocol.io/docs/develop/build-server) is a good place to start. +- Node.js 18+ +- Familiarity with the [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) -We'll use the [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) to build the server. +## 1. Project Setup -You'll also need Node.js 20+. - -## 1. Set up the project - -We'll set up a minimal TypeScript project with Vite for bundling. - -Start by creating a project directory: +Create a new directory and initialize: ```bash mkdir my-mcp-app && cd my-mcp-app -``` - -Install the dependencies you'll need: - -```bash npm init -y -npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/client@^2.0.0 @modelcontextprotocol/server@^2.0.0 @modelcontextprotocol/node@^2.0.0 @modelcontextprotocol/express@^2.0.0 zod@^4.2.0 express cors -npm install -D typescript vite vite-plugin-singlefile @types/express @types/cors @types/node tsx concurrently cross-env ``` -Configure your [`package.json`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/quickstart/package.json): +Install dependencies: ```bash -npm pkg set type=module -npm pkg set scripts.build="tsc --noEmit && tsc -p tsconfig.server.json && cross-env INPUT=mcp-app.html vite build" -npm pkg set scripts.start='concurrently --raw "cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch" "tsx watch main.ts"' +npm install github:modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod +npm install -D typescript vite vite-plugin-singlefile express cors @types/express @types/cors tsx ``` -> [!NOTE] -> Windows `cmd.exe` users will need to convert quotes in the above command: `npm pkg set scripts.start="concurrently --raw ""cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch"" ""tsx watch main.ts"""`. +Create `tsconfig.json`: -
-Create tsconfig.json: - - -```json source="../examples/quickstart/tsconfig.json" +```json { "compilerOptions": { - "types": ["node", "vite/client"], - "target": "ESNext", - "lib": ["ESNext", "DOM", "DOM.Iterable"], + "target": "ES2022", "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", "main.ts"] -} -``` - -
- -
-Create tsconfig.server.json โ€” for compiling server-side code: - - -```json source="../examples/quickstart/tsconfig.server.json" -{ - "compilerOptions": { - "types": ["node"], - "target": "ES2022", - "lib": ["ES2022"], - "module": "NodeNext", - "moduleResolution": "NodeNext", - "declaration": true, - "emitDeclarationOnly": true, - "outDir": "./dist", - "rootDir": ".", "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "outDir": "dist" }, - "include": ["server.ts", "main.ts"] + "include": ["*.ts", "src/**/*.ts"] } ``` -
+Create `vite.config.ts` โ€” this bundles your UI into a single HTML file: -
-Create vite.config.ts โ€” bundles UI into a single HTML file: - - -```ts source="../examples/quickstart/vite.config.ts" -import { createLogger, defineConfig } from "vite"; +```typescript +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"; - -const prefixedLogger = createLogger(); -for (const level of ["info", "warn", "error"] as const) { - const fn = prefixedLogger[level]; - prefixedLogger[level] = (msg, opts) => fn(msg.replace(/^/mg, "[vite] "), opts); -} - export default defineConfig({ - customLogger: prefixedLogger, plugins: [viteSingleFile()], build: { - sourcemap: isDevelopment ? "inline" : undefined, - cssMinify: !isDevelopment, - minify: !isDevelopment, - - rollupOptions: { - input: INPUT, - }, outDir: "dist", - emptyOutDir: false, + rollupOptions: { + input: process.env.INPUT, + }, }, }); ``` -
+Add to your `package.json`: -Your `my-mcp-app` directory should now contain: - -``` -my-mcp-app/ -โ”œโ”€โ”€ package.json -โ”œโ”€โ”€ tsconfig.json -โ”œโ”€โ”€ tsconfig.server.json -โ””โ”€โ”€ vite.config.ts +```json +{ + "type": "module", + "scripts": { + "build": "INPUT=mcp-app.html vite build", + "serve": "npx tsx server.ts" + } +} ``` -With the project scaffolded, let's write the server code. +**Full files:** [`package.json`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/package.json), [`tsconfig.json`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/tsconfig.json), [`vite.config.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/vite.config.ts) -## 2. Register the tool and UI resource +## 2. Create the Server MCP Apps use a **two-part registration**: 1. A **tool** that the LLM/host calls -2. A **resource** that contains the View HTML +2. A **resource** that serves the UI HTML -The tool's `_meta` field links them together via the resource's URI. When an MCP Apps-capable host calls the tool, it will also read the resource and render the View. +The tool's `_meta` field links them together. -Create [`server.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/quickstart/server.ts), which registers the tool and its UI resource: +Create `server.ts`: - -```ts source="../examples/quickstart/server.ts" -import { - registerAppResource, - registerAppTool, - RESOURCE_MIME_TYPE, -} from "@modelcontextprotocol/ext-apps/server"; -import { McpServer } from "@modelcontextprotocol/server"; +```typescript +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { RESOURCE_URI_META_KEY } from "@modelcontextprotocol/ext-apps"; +import cors from "cors"; +import express from "express"; import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; -const DIST_DIR = path.join(import.meta.dirname, "dist"); +const server = new McpServer({ + name: "My MCP App Server", + version: "1.0.0", +}); -/** - * Creates a new MCP server instance with tools and resources registered. - */ -export function createServer(): McpServer { - const server = new McpServer({ - name: "Quickstart MCP App Server", - version: "1.0.0", - }); +// Two-part registration: tool + resource +const resourceUri = "ui://get-time/mcp-app.html"; - // Two-part registration: tool + resource, tied together by the resource URI. - const resourceUri = "ui://get-time/mcp-app.html"; +server.registerTool( + "get-time", + { + title: "Get Time", + description: "Returns the current server time.", + inputSchema: {}, + outputSchema: { time: z.string() }, + _meta: { [RESOURCE_URI_META_KEY]: resourceUri }, // Links tool to UI + }, + async () => { + const time = new Date().toISOString(); + return { + content: [{ type: "text", text: time }], + structuredContent: { time }, + }; + }, +); - // 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.", - inputSchema: z.object({}), - _meta: { ui: { resourceUri } }, // Links this tool to its UI resource - }, - async () => { - const time = new Date().toISOString(); - return { content: [{ type: "text", text: time }] }; - }, +server.registerResource(resourceUri, resourceUri, {}, async () => { + const html = await fs.readFile( + path.join(import.meta.dirname, "dist", "mcp-app.html"), + "utf-8", ); - - // Register the resource, which returns the bundled HTML/JavaScript for the UI. - registerAppResource( - server, - resourceUri, - resourceUri, - { mimeType: RESOURCE_MIME_TYPE }, - async () => { - 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; -} -``` - -
-Create main.ts โ€” the entry point that starts the server: - - -```ts source="../examples/quickstart/main.ts" -import { createMcpExpressApp } from "@modelcontextprotocol/express"; -import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node"; -import type { McpServer } from "@modelcontextprotocol/server"; -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; -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. - * - * @param createServer - Factory function that creates a new McpServer instance per request. - */ -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 NodeStreamableHTTPServerTransport({ - 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)); + return { + contents: [ + { uri: resourceUri, mimeType: "text/html;profile=mcp-app", text: html }, + ], }; +}); - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); -} +// Express server for MCP endpoint +const app = express(); +app.use(cors()); +app.use(express.json()); -/** - * Starts an MCP server with stdio transport. - * - * @param createServer - Factory function that creates a new McpServer instance. - */ -export async function startStdioServer( - createServer: () => McpServer, -): Promise { - await createServer().connect(new StdioServerTransport()); -} +app.post("/mcp", async (req, res) => { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + res.on("close", () => transport.close()); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); +}); -async function main() { - if (process.argv.includes("--stdio")) { - await startStdioServer(createServer); - } else { - await startStreamableHTTPServer(createServer); +app.listen(3001, (err) => { + if (err) { + console.error("Error starting server:", err); + process.exit(1); } -} - -main().catch((e) => { - console.error(e); - process.exit(1); + console.log("Server listening on http://localhost:3001/mcp"); }); ``` -
+**Full file:** [`server.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/server.ts) -Your `my-mcp-app` directory should now contain: +## 3. Build the UI -``` -my-mcp-app/ -โ”œโ”€โ”€ main.ts -โ”œโ”€โ”€ package.json -โ”œโ”€โ”€ server.ts -โ”œโ”€โ”€ tsconfig.json -โ”œโ”€โ”€ tsconfig.server.json -โ””โ”€โ”€ vite.config.ts -``` +Create `mcp-app.html`: -Let's verify everything compiles: - -```bash -npx tsc --noEmit -``` - -No output means success! If you see errors, check for typos in `server.ts` or `main.ts`. - -The server can return the current time when the tool is called. Now let's build the UI to display it. - -## 3. Build the View - -The View consists of an HTML page and a script that connects to the host. - -Create [`mcp-app.html`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/quickstart/mcp-app.html), the HTML for your View: - - -```html source="../examples/quickstart/mcp-app.html" +```html @@ -380,11 +185,10 @@ Create [`mcp-app.html`](https://github.com/modelcontextprotocol/ext-apps/blob/ma ``` -Create [`src/mcp-app.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/quickstart/src/mcp-app.ts), which connects to the host and handles user interactions: +Create `src/mcp-app.ts`: - -```ts source="../examples/quickstart/src/mcp-app.ts" -import { App } from "@modelcontextprotocol/ext-apps"; +```typescript +import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps"; // Get element references const serverTimeEl = document.getElementById("server-time")!; @@ -393,95 +197,61 @@ const getTimeBtn = document.getElementById("get-time-btn")!; // Create app instance const app = new App({ name: "Get Time App", version: "1.0.0" }); -// Handle tool results from the server. Set before `app.connect()` to avoid -// missing the initial tool result. +// Register handlers BEFORE connecting app.ontoolresult = (result) => { - const time = result.content?.find((c) => c.type === "text")?.text; + const { time } = (result.structuredContent as { time?: string }) ?? {}; serverTimeEl.textContent = time ?? "[ERROR]"; }; // Wire up button click getTimeBtn.addEventListener("click", async () => { - // `app.callServerTool()` lets the UI request fresh data from the server const result = await app.callServerTool({ name: "get-time", arguments: {} }); - const time = result.content?.find((c) => c.type === "text")?.text; + const { time } = (result.structuredContent as { time?: string }) ?? {}; serverTimeEl.textContent = time ?? "[ERROR]"; }); // Connect to host -app.connect(); +app.connect(new PostMessageTransport(window.parent)); ``` -Your `my-mcp-app` directory should now contain: - -``` -my-mcp-app/ -โ”œโ”€โ”€ main.ts -โ”œโ”€โ”€ mcp-app.html -โ”œโ”€โ”€ package.json -โ”œโ”€โ”€ server.ts -โ”œโ”€โ”€ src/ -โ”‚ โ””โ”€โ”€ mcp-app.ts -โ”œโ”€โ”€ tsconfig.json -โ”œโ”€โ”€ tsconfig.server.json -โ””โ”€โ”€ vite.config.ts -``` - -Now let's build the bundled View: +Build the UI: ```bash npm run build ``` -This produces `dist/mcp-app.html`: +This produces `dist/mcp-app.html` containing your bundled app. -```console -$ ls dist/ -mcp-app.html -``` +**Full files:** [`mcp-app.html`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/mcp-app.html), [`src/mcp-app.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/src/mcp-app.ts) -The View will connect to the host, receive the tool result, and display it. Let's see it in action! +## 4. Test It -## 4. See it in action +You'll need two terminals. -You'll need two terminal windows. - -**Terminal 1** โ€” Start your server (with watch mode): +**Terminal 1** โ€” Build and start your server: ```bash -npm start -``` - -You should see: - -```console -MCP server listening on http://localhost:3001/mcp +npm run build && npm run serve ``` **Terminal 2** โ€” Run the test host (from the [ext-apps repo](https://github.com/modelcontextprotocol/ext-apps)): ```bash git clone https://github.com/modelcontextprotocol/ext-apps.git -cd ext-apps +cd ext-apps/examples/basic-host npm install -cd examples/basic-host -npm start +npm run start ``` Open http://localhost:8080 in your browser: 1. Select **get-time** from the "Tool Name" dropdown 2. Click **Call Tool** -3. Your View renders in the sandbox below +3. Your UI renders in the sandbox below 4. Click **Get Server Time** โ€” the current time appears! -![Your first MCP App](./quickstart-success.png) - -You've built your first MCP App! - ## Next Steps -- **Continue learning**: The [`basic-server-vanillajs`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs) example builds on this quickstart with host communication, theming, and lifecycle handlers +- **Host communication**: Add [`sendMessage()`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendmessage), [`sendLog()`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendlog), and [`sendOpenLink()`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendopenlink) to interact with the host โ€” see [`src/mcp-app.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/src/mcp-app.ts) - **React version**: Compare with [`basic-server-react`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react) for a React-based UI -- **Other frameworks**: See also [Vue](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vue), [Svelte](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-svelte), [Preact](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-preact), and [Solid](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-solid) examples -- **API reference**: See the full [API documentation](https://apps.extensions.modelcontextprotocol.io/api/) +- **API reference**: See the full [API documentation](https://modelcontextprotocol.github.io/ext-apps/api/) diff --git a/docs/testing-mcp-apps.md b/docs/testing-mcp-apps.md deleted file mode 100644 index a8eb333..0000000 --- a/docs/testing-mcp-apps.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -title: Testing MCP Apps -group: Getting Started -description: Test MCP Apps locally with the basic-host reference implementation or in production hosts like Claude.ai and VS Code. ---- - -# Test Your MCP App - -This guide covers two approaches for testing your MCP App: using the `basic-host` reference implementation for local development, or using an MCP Apps-compatible host like Claude\.ai or VS Code. - -## Test with basic-host - -The [`basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example in this repository is a reference host implementation that lets you select a tool, call it, and see your App UI rendered in a sandboxed iframe. - -**Prerequisites:** - -- Node.js installed -- Your MCP server running locally (e.g., at `http://localhost:3001/mcp`) - -**Steps:** - -1. Clone the repository and install dependencies: - - ```bash - git clone https://github.com/modelcontextprotocol/ext-apps.git - cd ext-apps - npm install - cd examples/basic-host - ``` - - The root install runs the package build, including type-checking the - documentation snippets against the pinned base MCP SDK 2.x packages. Do - not bypass a failed build before testing the host. - -2. Start basic-host, pointing it to your MCP server: - - ```bash - SERVERS='["http://localhost:3001/mcp"]' npm start - ``` - - To connect to multiple servers, list them in the array: - - ```bash - SERVERS='["http://localhost:3001/mcp", "http://localhost:3002/mcp"]' npm start - ``` - -3. Open http://localhost:8080 in your browser. - -4. Select your server from the dropdown, then select a tool with UI support. - -5. Enter any required tool input as JSON and click "Call Tool" to see your App render. - -### Debugging with basic-host - -The basic-host UI includes collapsible panels to help you debug your App: - -- **Tool Input** โ€” The JSON input sent to your tool -- **Tool Result** โ€” The result returned by your tool -- **Messages** โ€” Messages sent by your App to the model -- **Model Context** โ€” Context updates sent by your App - -For additional observability, open your browser's developer console. Basic-host logs key events with a `[HOST]` prefix, including server connections, tool calls, App initialization, and App-to-host requests. - -## Test with an MCP Apps-compatible host - -To test your App in a real conversational environment, install your MCP server in a host that supports MCP Apps: - -- Claude\.ai - - [Remote MCP servers (over HTTP)](https://claude.com/docs/connectors/custom/remote-mcp) - - [Local MCP servers (over stdio)](https://claude.com/docs/connectors/custom/desktop-extensions) -- [VS Code (Insiders)](https://code.visualstudio.com/docs/copilot/customization/mcp-servers) -- [Goose](https://block.github.io/goose/docs/getting-started/using-extensions/) - -Once your server is configured, ask the agent to perform a task related to your App-enhanced tool. For example, if you have a weather App, ask the agent "Show me the current weather." - -## Expose local servers with `cloudflared` - -Remote hosts like Claude\.ai cannot reach `localhost`. To test a local HTTP server with a remote host, use [`cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/create-local-tunnel/) to create a publicly accessible tunnel: - -1. Start your MCP server locally (e.g., at `http://localhost:3001/mcp`). - -2. Run `cloudflared` to expose your server: - - ```bash - npx cloudflared tunnel --url http://localhost:3001 - ``` - -3. Copy the generated URL from the `cloudflared` output (e.g., `https://random-name.trycloudflare.com`). - -4. Add that URL as a remote MCP server in your host, appending your MCP endpoint path (e.g., `https://random-name.trycloudflare.com/mcp`). - -> [!NOTE] -> The tunnel URL changes each time you restart `cloudflared`. diff --git a/examples/basic-host/.gitignore b/examples/basic-host/.gitignore deleted file mode 100644 index b947077..0000000 --- a/examples/basic-host/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -dist/ diff --git a/examples/basic-host/README.md b/examples/basic-host/README.md index c64cda7..24be409 100644 --- a/examples/basic-host/README.md +++ b/examples/basic-host/README.md @@ -1,6 +1,6 @@ # Example: Basic Host -A reference implementation showing how to build an MCP host application that connects to MCP servers and renders tool UIs in a secure sandbox. +A reference implementation showing how to build an MCP Host application that connects to MCP servers and renders tool UIs in a secure sandbox. This basic host can also be used to test MCP Apps during local development. @@ -14,16 +14,10 @@ This basic host can also be used to test MCP Apps during local development. ```bash npm install -npm run start +npm run dev # Open http://localhost:8080 ``` -By default, the host application will try to connect to an MCP server at `http://localhost:3001/mcp`. You can configure this behavior by setting the `SERVERS` environment variable with a JSON array of server URLs: - -```bash -SERVERS='["http://localhost:1234/mcp", "http://localhost:5678/mcp"]' npm run start -``` - ## Architecture This example uses a double-iframe sandbox pattern for secure UI isolation: diff --git a/examples/basic-host/index.html b/examples/basic-host/index.html index 68e8109..b865ebe 100644 --- a/examples/basic-host/index.html +++ b/examples/basic-host/index.html @@ -3,7 +3,6 @@ - MCP Apps Host diff --git a/examples/basic-host/package.json b/examples/basic-host/package.json index cefe9d5..6a686b6 100644 --- a/examples/basic-host/package.json +++ b/examples/basic-host/package.json @@ -1,34 +1,36 @@ { "homepage": "https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host", "name": "@modelcontextprotocol/ext-apps-basic-host", - "version": "2.0.0", + "version": "1.0.0", "type": "module", "scripts": { - "build": "tsc --noEmit && concurrently \"cross-env INPUT=index.html vite build\" \"cross-env INPUT=sandbox.html vite build\"", - "watch": "concurrently \"cross-env INPUT=index.html vite build --watch\" \"cross-env INPUT=sandbox.html vite build --watch\"", - "serve": "bun --watch serve.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\"" + "build": "concurrently 'INPUT=index.html vite build' 'INPUT=sandbox.html vite build'", + "watch": "concurrently 'INPUT=index.html vite build --watch' 'INPUT=sandbox.html vite build --watch'", + "serve": "bun serve.ts", + "start": "NODE_ENV=development npm run build && npm run serve", + "dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve'" }, "dependencies": { - "@modelcontextprotocol/client": "2.0.0", - "@modelcontextprotocol/ext-apps": "^2.0.0", + "@modelcontextprotocol/ext-apps": "../..", + "@modelcontextprotocol/sdk": "^1.22.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "zod": "^4.2.0" + "zod": "^3.25.0" }, "devDependencies": { "@types/express": "^5.0.0", - "@types/node": "22.10.0", + "@types/node": "^22.0.0", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^4.3.4", + "bun": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", "prettier": "^3.6.2", - "typescript": "^5.9.3", "vite": "^6.0.0", - "vite-plugin-singlefile": "^2.3.0" + "vite-plugin-singlefile": "^2.3.0", + "typescript": "^5.9.3", + "vitest": "^3.2.4" } } diff --git a/examples/basic-host/sandbox.html b/examples/basic-host/sandbox.html index fb4cd8d..f09d494 100644 --- a/examples/basic-host/sandbox.html +++ b/examples/basic-host/sandbox.html @@ -2,9 +2,22 @@ - - + + MCP-UI Proxy diff --git a/examples/basic-host/serve.ts b/examples/basic-host/serve.ts index bc3fbff..450ec57 100644 --- a/examples/basic-host/serve.ts +++ b/examples/basic-host/serve.ts @@ -2,19 +2,15 @@ /** * HTTP servers for the MCP UI example: * - Host server (port 8080): serves host HTML files (React and Vanilla examples) - * - Sandbox server (port 8081): serves sandbox.html with CSP headers + * - Sandbox server (port 8081): serves sandbox.html with permissive CSP * * Running on separate ports ensures proper origin isolation for security. - * - * Security: CSP is set via HTTP headers based on ?csp= query param. - * This ensures content cannot tamper with CSP (unlike meta tags). */ import express from "express"; import cors from "cors"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; -import type { McpUiResourceCsp } from "@modelcontextprotocol/ext-apps"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -22,9 +18,6 @@ const __dirname = dirname(__filename); const HOST_PORT = parseInt(process.env.HOST_PORT || "8080", 10); const SANDBOX_PORT = parseInt(process.env.SANDBOX_PORT || "8081", 10); const DIRECTORY = join(__dirname, "dist"); -const SERVERS: string[] = process.env.SERVERS - ? JSON.parse(process.env.SERVERS) - : ["http://localhost:3001/mcp"]; // ============ Host Server (port 8080) ============ const hostApp = express(); @@ -41,11 +34,6 @@ hostApp.use((req, res, next) => { hostApp.use(express.static(DIRECTORY)); -// API endpoint to get configured server URLs -hostApp.get("/api/servers", (_req, res) => { - res.json(SERVERS); -}); - hostApp.get("/", (_req, res) => { res.redirect("/index.html"); }); @@ -54,76 +42,26 @@ hostApp.get("/", (_req, res) => { const sandboxApp = express(); sandboxApp.use(cors()); -// Validate CSP domain entries to prevent injection attacks. -// Rejects entries containing characters that could: -// - `;` or newlines: break out to new CSP directive -// - quotes: inject CSP keywords like 'unsafe-eval' -// - space: inject multiple sources in one entry -function sanitizeCspDomains(domains?: string[]): string[] { - if (!domains) return []; - return domains.filter((d) => typeof d === "string" && !/[;\r\n'" ]/.test(d)); -} - -function buildCspHeader(csp?: McpUiResourceCsp): string { - const resourceDomains = sanitizeCspDomains(csp?.resourceDomains).join(" "); - const connectDomains = sanitizeCspDomains(csp?.connectDomains).join(" "); - const frameDomains = sanitizeCspDomains(csp?.frameDomains).join(" ") || null; - const baseUriDomains = - sanitizeCspDomains(csp?.baseUriDomains).join(" ") || null; - - const directives = [ - // Default: allow same-origin + inline styles/scripts (needed for bundled apps) - "default-src 'self' 'unsafe-inline'", - // Scripts: same-origin + inline + eval (some libs need eval) + blob (workers) + specified domains - `script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: ${resourceDomains}`.trim(), - // Styles: same-origin + inline + specified domains - `style-src 'self' 'unsafe-inline' blob: data: ${resourceDomains}`.trim(), - // Images: same-origin + data/blob URIs + specified domains - `img-src 'self' data: blob: ${resourceDomains}`.trim(), - // Fonts: same-origin + data/blob URIs + specified domains - `font-src 'self' data: blob: ${resourceDomains}`.trim(), - // Media (audio/video): same-origin + data/blob URIs + specified domains - `media-src 'self' data: blob: ${resourceDomains}`.trim(), - // Network requests: same-origin + specified API/tile domains - `connect-src 'self' ${connectDomains}`.trim(), - // Workers: same-origin + blob (dynamic workers) + specified domains - // This is critical for WebGL apps (CesiumJS, Three.js) that use workers for: - // - Tile decoding and terrain processing - // - Image processing and texture loading - // - Physics and geometry calculations - `worker-src 'self' blob: ${resourceDomains}`.trim(), - // Nested iframes: use frameDomains if provided, otherwise block all - frameDomains ? `frame-src ${frameDomains}` : "frame-src 'none'", - // Plugins: always blocked (defense in depth) - "object-src 'none'", - // Base URI: use baseUriDomains if provided, otherwise block all - baseUriDomains ? `base-uri ${baseUriDomains}` : "base-uri 'none'", - ]; - - return directives.join("; "); -} - -// Serve sandbox.html with CSP from query params -sandboxApp.get(["/", "/sandbox.html"], (req, res) => { - // Parse CSP config from query param: ?csp= - let cspConfig: McpUiResourceCsp | undefined; - if (typeof req.query.csp === "string") { - try { - cspConfig = JSON.parse(req.query.csp); - } catch (e) { - console.warn("[Sandbox] Invalid CSP query param:", e); - } - } - - // Set CSP via HTTP header - tamper-proof unlike meta tags - const cspHeader = buildCspHeader(cspConfig); - res.setHeader("Content-Security-Policy", cspHeader); - - // Prevent caching to ensure fresh CSP on each load +// Permissive CSP for sandbox content +sandboxApp.use((_req, res, next) => { + const csp = [ + "default-src 'self'", + "img-src * data: blob: 'unsafe-inline'", + "style-src * blob: data: 'unsafe-inline'", + "script-src * blob: data: 'unsafe-inline' 'unsafe-eval'", + "connect-src *", + "font-src * blob: data:", + "media-src * blob: data:", + "frame-src * blob: data:", + ].join("; "); + res.setHeader("Content-Security-Policy", csp); res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); res.setHeader("Pragma", "no-cache"); res.setHeader("Expires", "0"); + next(); +}); +sandboxApp.get(["/", "/sandbox.html"], (_req, res) => { res.sendFile(join(DIRECTORY, "sandbox.html")); }); @@ -132,7 +70,7 @@ sandboxApp.use((_req, res) => { }); // ============ Start both servers ============ -hostApp.listen(HOST_PORT, (err) => { +hostApp.listen(HOST_PORT, err => { if (err) { console.error("Error starting server:", err); process.exit(1); @@ -140,7 +78,7 @@ hostApp.listen(HOST_PORT, (err) => { console.log(`Host server: http://localhost:${HOST_PORT}`); }); -sandboxApp.listen(SANDBOX_PORT, (err) => { +sandboxApp.listen(SANDBOX_PORT, err => { if (err) { console.error("Error starting server:", err); process.exit(1); diff --git a/examples/basic-host/src/global.css b/examples/basic-host/src/global.css index 74b5751..97cda44 100644 --- a/examples/basic-host/src/global.css +++ b/examples/basic-host/src/global.css @@ -1,38 +1,10 @@ -:root { - color-scheme: light dark; - - /* Light theme (default) */ - --color-bg: #ffffff; - --color-bg-secondary: #f5f5f5; - --color-text: #1f2937; - --color-text-secondary: #6b7280; - --color-border: #e5e7eb; - --color-primary: #1e3a5f; - --color-primary-hover: #2d4a7c; -} - -[data-theme="dark"] { - --color-bg: #1a1a1a; - --color-bg-secondary: #2d2d2d; - --color-text: #f3f4f6; - --color-text-secondary: #9ca3af; - --color-border: #404040; - --color-primary: #3b82f6; - --color-primary-hover: #60a5fa; -} - * { box-sizing: border-box; } html, body { - margin: 0; - padding: 0; font-family: system-ui, -apple-system, sans-serif; font-size: 1rem; - background-color: var(--color-bg); - color: var(--color-text); - transition: background-color 0.2s, color 0.2s; } code { diff --git a/examples/basic-host/src/host-styles.ts b/examples/basic-host/src/host-styles.ts deleted file mode 100644 index f6d4887..0000000 --- a/examples/basic-host/src/host-styles.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * MCP style variables for the basic-host example. - * These are passed to apps via hostContext.styles.variables. - */ -import type { McpUiStyles } from "@modelcontextprotocol/ext-apps"; - -/** - * MCP App style variables using light-dark() for theme adaptation. - * Apps receive these and can use them as CSS custom properties. - */ -export const HOST_STYLE_VARIABLES: McpUiStyles = { - // Background colors - using light-dark() for automatic adaptation - "--color-background-primary": "light-dark(#ffffff, #1a1a1a)", - "--color-background-secondary": "light-dark(#f5f5f5, #2d2d2d)", - "--color-background-tertiary": "light-dark(#e5e5e5, #404040)", - "--color-background-inverse": "light-dark(#1a1a1a, #ffffff)", - "--color-background-ghost": "light-dark(rgba(255,255,255,0), rgba(26,26,26,0))", - "--color-background-info": "light-dark(#eff6ff, #1e3a5f)", - "--color-background-danger": "light-dark(#fef2f2, #7f1d1d)", - "--color-background-success": "light-dark(#f0fdf4, #14532d)", - "--color-background-warning": "light-dark(#fefce8, #713f12)", - "--color-background-disabled": "light-dark(rgba(255,255,255,0.5), rgba(26,26,26,0.5))", - - // Text colors - "--color-text-primary": "light-dark(#1f2937, #f3f4f6)", - "--color-text-secondary": "light-dark(#6b7280, #9ca3af)", - "--color-text-tertiary": "light-dark(#9ca3af, #6b7280)", - "--color-text-inverse": "light-dark(#f3f4f6, #1f2937)", - "--color-text-ghost": "light-dark(rgba(107,114,128,0.5), rgba(156,163,175,0.5))", - "--color-text-info": "light-dark(#1d4ed8, #60a5fa)", - "--color-text-danger": "light-dark(#b91c1c, #f87171)", - "--color-text-success": "light-dark(#15803d, #4ade80)", - "--color-text-warning": "light-dark(#a16207, #fbbf24)", - "--color-text-disabled": "light-dark(rgba(31,41,55,0.5), rgba(243,244,246,0.5))", - - // Border colors - "--color-border-primary": "light-dark(#e5e7eb, #404040)", - "--color-border-secondary": "light-dark(#d1d5db, #525252)", - "--color-border-tertiary": "light-dark(#f3f4f6, #374151)", - "--color-border-inverse": "light-dark(rgba(255,255,255,0.3), rgba(0,0,0,0.3))", - "--color-border-ghost": "light-dark(rgba(229,231,235,0), rgba(64,64,64,0))", - "--color-border-info": "light-dark(#93c5fd, #1e40af)", - "--color-border-danger": "light-dark(#fca5a5, #991b1b)", - "--color-border-success": "light-dark(#86efac, #166534)", - "--color-border-warning": "light-dark(#fde047, #854d0e)", - "--color-border-disabled": "light-dark(rgba(229,231,235,0.5), rgba(64,64,64,0.5))", - - // Ring colors (focus) - "--color-ring-primary": "light-dark(#3b82f6, #60a5fa)", - "--color-ring-secondary": "light-dark(#6b7280, #9ca3af)", - "--color-ring-inverse": "light-dark(#ffffff, #1f2937)", - "--color-ring-info": "light-dark(#2563eb, #3b82f6)", - "--color-ring-danger": "light-dark(#dc2626, #ef4444)", - "--color-ring-success": "light-dark(#16a34a, #22c55e)", - "--color-ring-warning": "light-dark(#ca8a04, #eab308)", - - // Typography - Family - "--font-sans": "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", - "--font-mono": "ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', monospace", - - // Typography - Weight - "--font-weight-normal": "400", - "--font-weight-medium": "500", - "--font-weight-semibold": "600", - "--font-weight-bold": "700", - - // Typography - Text Size - "--font-text-xs-size": "0.75rem", - "--font-text-sm-size": "0.875rem", - "--font-text-md-size": "1rem", - "--font-text-lg-size": "1.125rem", - - // Typography - Heading Size - "--font-heading-xs-size": "0.75rem", - "--font-heading-sm-size": "0.875rem", - "--font-heading-md-size": "1rem", - "--font-heading-lg-size": "1.25rem", - "--font-heading-xl-size": "1.5rem", - "--font-heading-2xl-size": "1.875rem", - "--font-heading-3xl-size": "2.25rem", - - // Typography - Text Line Height - "--font-text-xs-line-height": "1.4", - "--font-text-sm-line-height": "1.4", - "--font-text-md-line-height": "1.5", - "--font-text-lg-line-height": "1.5", - - // Typography - Heading Line Height - "--font-heading-xs-line-height": "1.4", - "--font-heading-sm-line-height": "1.4", - "--font-heading-md-line-height": "1.4", - "--font-heading-lg-line-height": "1.3", - "--font-heading-xl-line-height": "1.25", - "--font-heading-2xl-line-height": "1.2", - "--font-heading-3xl-line-height": "1.1", - - // Border radius - "--border-radius-xs": "2px", - "--border-radius-sm": "4px", - "--border-radius-md": "6px", - "--border-radius-lg": "8px", - "--border-radius-xl": "12px", - "--border-radius-full": "9999px", - - // Border width - "--border-width-regular": "1px", - - // Shadows - "--shadow-hairline": "0 1px 2px 0 rgba(0, 0, 0, 0.05)", - "--shadow-sm": "0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)", - "--shadow-md": "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)", - "--shadow-lg": "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)", -}; diff --git a/examples/basic-host/src/implementation.ts b/examples/basic-host/src/implementation.ts index 5f680ff..614aeea 100644 --- a/examples/basic-host/src/implementation.ts +++ b/examples/basic-host/src/implementation.ts @@ -1,17 +1,11 @@ -import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp, type McpUiResourcePermissions, buildAllowAttribute, type McpUiUpdateModelContextRequest, type McpUiMessageRequest } from "@modelcontextprotocol/ext-apps/app-bridge"; -import { - Client, - SSEClientTransport, - StreamableHTTPClientTransport, - type CallToolResult, - type Resource, - type Tool, -} from "@modelcontextprotocol/client"; -import { getTheme, onThemeChange } from "./theme"; -import { HOST_STYLE_VARIABLES } from "./host-styles"; +import { RESOURCE_URI_META_KEY, type McpUiSandboxProxyReadyNotification } from "@modelcontextprotocol/ext-apps"; +import { AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; -const SANDBOX_PROXY_BASE_URL = "http://localhost:8081/sandbox.html"; +const SANDBOX_PROXY_URL = new URL("http://localhost:8081/sandbox.html"); const IMPLEMENTATION = { name: "MCP Apps Host", version: "1.0.0" }; @@ -23,72 +17,38 @@ export const log = { export interface ServerInfo { - name: string; client: Client; tools: Map; - resources: Map; appHtmlCache: Map; } export async function connectToServer(serverUrl: URL): Promise { - log.info("Connecting to server:", serverUrl.href); - const client = await connectWithFallback(serverUrl); + const client = new Client(IMPLEMENTATION); - const name = client.getServerVersion()?.name ?? serverUrl.href; + log.info("Connecting to server:", serverUrl.href); + await client.connect(new StreamableHTTPClientTransport(serverUrl)); + log.info("Connection successful"); const toolsList = await client.listTools(); const tools = new Map(toolsList.tools.map((tool) => [tool.name, tool])); log.info("Server tools:", Array.from(tools.keys())); - // Fetch resources for listing-level _meta.ui (fallback for content-level) - const resourcesList = await client.listResources(); - const resources = new Map(resourcesList.resources.map((r) => [r.uri, r])); - log.info("Server resources:", Array.from(resources.keys())); - - return { name, client, tools, resources, appHtmlCache: new Map() }; + return { client, tools, appHtmlCache: new Map() }; } -async function connectWithFallback(serverUrl: URL): Promise { - // Try Streamable HTTP first (modern transport) - try { - const client = new Client(IMPLEMENTATION); - await client.connect(new StreamableHTTPClientTransport(serverUrl)); - log.info("Connected via Streamable HTTP transport"); - return client; - } catch (streamableError) { - log.info("Streamable HTTP failed:", streamableError); - } - - // Fall back to SSE (deprecated but needed for older servers) - try { - const client = new Client(IMPLEMENTATION); - await client.connect(new SSEClientTransport(serverUrl)); - log.info("Connected via SSE transport"); - return client; - } catch (sseError) { - throw new Error(`Could not connect with any transport. SSE error: ${sseError}`); - } -} - - -interface UiResourceData { - html: string; - csp?: McpUiResourceCsp; - permissions?: McpUiResourcePermissions; -} export interface ToolCallInfo { serverInfo: ServerInfo; tool: Tool; input: Record; resultPromise: Promise; - appResourcePromise?: Promise; + appHtmlPromise?: Promise; } export function hasAppHtml(toolCallInfo: ToolCallInfo): toolCallInfo is Required { - return !!toolCallInfo.appResourcePromise; + return !!toolCallInfo.appHtmlPromise; } @@ -107,16 +67,32 @@ export function callTool( const toolCallInfo: ToolCallInfo = { serverInfo, tool, input, resultPromise }; - const uiResourceUri = getToolUiResourceUri(tool); + const uiResourceUri = getUiResourceUri(tool); if (uiResourceUri) { - toolCallInfo.appResourcePromise = getUiResource(serverInfo, uiResourceUri); + toolCallInfo.appHtmlPromise = getUiResourceHtml(serverInfo, uiResourceUri); } return toolCallInfo; } -async function getUiResource(serverInfo: ServerInfo, uri: string): Promise { +function getUiResourceUri(tool: Tool): string | undefined { + const uri = tool._meta?.[RESOURCE_URI_META_KEY]; + if (typeof uri === "string" && uri.startsWith("ui://")) { + return uri; + } else if (uri !== undefined) { + throw new Error(`Invalid UI resource URI: ${JSON.stringify(uri)}`); + } +} + + +async function getUiResourceHtml(serverInfo: ServerInfo, uri: string): Promise { + let html = serverInfo.appHtmlCache.get(uri); + if (html) { + log.info("Read UI resource from cache:", uri); + return html; + } + log.info("Reading UI resource:", uri); const resource = await serverInfo.client.readResource({ uri }); @@ -132,50 +108,22 @@ async function getUiResource(serverInfo: ServerInfo, uri: string): Promise { +export function loadSandboxProxy(iframe: HTMLIFrameElement): Promise { // Prevent reload if (iframe.src) return Promise.resolve(false); iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); - // Set Permission Policy allow attribute based on requested permissions - const allowAttribute = buildAllowAttribute(permissions); - if (allowAttribute) { - iframe.setAttribute("allow", allowAttribute); - } - const readyNotification: McpUiSandboxProxyReadyNotification["method"] = "ui/notifications/sandbox-proxy-ready"; @@ -190,14 +138,8 @@ export function loadSandboxProxy( window.addEventListener("message", listener); }); - // Build sandbox URL with CSP query param for HTTP header-based CSP - const sandboxUrl = new URL(SANDBOX_PROXY_BASE_URL); - if (csp) { - sandboxUrl.searchParams.set("csp", JSON.stringify(csp)); - } - - log.info("Loading sandbox proxy...", csp ? `(CSP: ${JSON.stringify(csp)})` : ""); - iframe.src = sandboxUrl.href; + log.info("Loading sandbox proxy..."); + iframe.src = SANDBOX_PROXY_URL.href; return readyPromise; } @@ -206,7 +148,7 @@ export function loadSandboxProxy( export async function initializeApp( iframe: HTMLIFrameElement, appBridge: AppBridge, - { input, resultPromise, appResourcePromise }: Required, + { input, resultPromise, appHtmlPromise }: Required, ): Promise { const appInitializedPromise = hookInitializedCallback(appBridge); @@ -218,10 +160,9 @@ export async function initializeApp( new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!), ); - // Load inner iframe HTML with CSP and permissions metadata - const { html, csp, permissions } = await appResourcePromise; - log.info("Sending UI resource HTML to MCP App", csp ? `(CSP: ${JSON.stringify(csp)})` : "", permissions ? `(Permissions: ${JSON.stringify(permissions)})` : ""); - await appBridge.sendSandboxResourceReady({ html, csp, permissions }); + // Load inner iframe HTML + log.info("Sending UI resource HTML to MCP App"); + await appBridge.sendSandboxResourceReady({ html: await appHtmlPromise }); // Wait for inner iframe to be ready log.info("Waiting for MCP App to initialize..."); @@ -232,19 +173,11 @@ export async function initializeApp( log.info("Sending tool call input to MCP App:", input); appBridge.sendToolInput({ arguments: input }); - // Schedule tool call result (or cancellation) to be sent to MCP App - resultPromise.then( - (result) => { - log.info("Sending tool call result to MCP App:", result); - appBridge.sendToolResult(result); - }, - (error) => { - log.error("Tool call failed, sending cancellation to MCP App:", error); - appBridge.sendToolCancelled({ - reason: error instanceof Error ? error.message : String(error), - }); - }, - ); + // Schedule tool call result to be sent to MCP App + resultPromise.then((result) => { + log.info("Sending tool call result to MCP App:", result); + appBridge.sendToolResult(result); + }); } /** @@ -263,82 +196,20 @@ function hookInitializedCallback(appBridge: AppBridge): Promise { } -export type ModelContext = McpUiUpdateModelContextRequest["params"]; -export type AppMessage = McpUiMessageRequest["params"]; - -export interface AppBridgeCallbacks { - onContextUpdate?: (context: ModelContext | null) => void; - onMessage?: (message: AppMessage) => void; - onDisplayModeChange?: (mode: "inline" | "fullscreen") => void; -} - -export interface AppBridgeOptions { - containerDimensions?: { maxHeight?: number; width?: number } | { height: number; width?: number }; - displayMode?: "inline" | "fullscreen"; -} - -export function newAppBridge( - serverInfo: ServerInfo, - iframe: HTMLIFrameElement, - callbacks?: AppBridgeCallbacks, - options?: AppBridgeOptions, -): AppBridge { +export function newAppBridge(serverInfo: ServerInfo, iframe: HTMLIFrameElement): AppBridge { const serverCapabilities = serverInfo.client.getServerCapabilities(); const appBridge = new AppBridge(serverInfo.client, IMPLEMENTATION, { openLinks: {}, serverTools: serverCapabilities?.tools, serverResources: serverCapabilities?.resources, - // Declare support for model context updates - updateModelContext: { text: {} }, - }, { - // Pass initial host context with theme, display mode, and style variables - hostContext: { - theme: getTheme(), - platform: "web", - styles: { - variables: HOST_STYLE_VARIABLES, - }, - containerDimensions: options?.containerDimensions ?? { maxHeight: 6000 }, - displayMode: options?.displayMode ?? "inline", - availableDisplayModes: ["inline", "fullscreen"], - }, }); - // Listen for theme changes (from toggle or system) and notify the app - onThemeChange((newTheme) => { - log.info("Theme changed:", newTheme); - appBridge.sendHostContextChange({ theme: newTheme }); - }); - - // Per spec, the host SHOULD notify the view when container dimensions - // change. A ResizeObserver on the iframe covers window resize, layout - // shifts, and the inlineโ†”fullscreen panel toggle (which React applies - // a tick after onrequestdisplaymode returns โ€” sending containerDimensions - // alongside displayMode there would race the layout). Height stays - // flexible (maxHeight) so the view can keep driving it via sendSizeChanged. - const iframeResizeObserver = new ResizeObserver(([entry]) => { - const width = Math.round(entry.contentRect.width); - if (width > 0) { - appBridge.sendHostContextChange({ - containerDimensions: { width, maxHeight: 6000 }, - }); - } - }); - iframeResizeObserver.observe(iframe); - // AppBridge inherits Protocol's onclose hook โ€” chain disposal there. - const prevOnclose = appBridge.onclose; - appBridge.onclose = () => { - iframeResizeObserver.disconnect(); - prevOnclose?.(); - }; - - // Register all handlers before calling connect(). The view can start + // Register all handlers before calling connect(). The Guest UI can start // sending requests immediately after the initialization handshake, so any // handlers registered after connect() might miss early requests. appBridge.onmessage = async (params, _extra) => { log.info("Message from MCP App:", params); - callbacks?.onMessage?.(params); return {}; }; @@ -352,15 +223,6 @@ export function newAppBridge( log.info("Log message from MCP App:", params); }; - appBridge.onupdatemodelcontext = async (params) => { - log.info("Model context update from MCP App:", params); - // Normalize: empty content array means clear context - const hasContent = params.content && params.content.length > 0; - const hasStructured = params.structuredContent && Object.keys(params.structuredContent).length > 0; - callbacks?.onContextUpdate?.(hasContent || hasStructured ? params : null); - return {}; - }; - appBridge.onsizechange = async ({ width, height }) => { // The MCP App has requested a `width` and `height`, but if // `box-sizing: border-box` is applied to the outer iframe element, then we @@ -396,18 +258,5 @@ export function newAppBridge( iframe.animate([from, to], { duration: 300, easing: "ease-out" }); }; - // Handle display mode change requests from the app - appBridge.onrequestdisplaymode = async (params) => { - log.info("Display mode request from MCP App:", params); - const newMode = params.mode === "fullscreen" ? "fullscreen" : "inline"; - // Update host context and notify the app - appBridge.sendHostContextChange({ - displayMode: newMode, - }); - // Notify the host UI (via callback) - callbacks?.onDisplayModeChange?.(newMode); - return { mode: newMode }; - }; - return appBridge; } diff --git a/examples/basic-host/src/index.module.css b/examples/basic-host/src/index.module.css index 0bd09dd..961d078 100644 --- a/examples/basic-host/src/index.module.css +++ b/examples/basic-host/src/index.module.css @@ -1,34 +1,14 @@ -/* Theme toggle button */ -.themeToggle { - position: fixed; - bottom: 1rem; - right: 1rem; - width: 2.5rem; - height: 2.5rem; - padding: 0; - border: 1px solid var(--color-border); - border-radius: 50%; - background-color: var(--color-bg-secondary); - font-size: 1.25rem; - cursor: pointer; - z-index: 1000; - transition: background-color 0.2s, border-color 0.2s, transform 0.1s; - - &:hover { - background-color: var(--color-border); - } - - &:active { - transform: scale(0.95); - } +.connecting { + padding: 1rem 0; + text-align: center; + color: #666; } .callToolPanel, .toolCallInfoPanel { margin: 0 auto; padding: 1rem; - border: 1px solid var(--color-border); + border: 1px solid #ddd; border-radius: 4px; - background-color: var(--color-bg-secondary); * + & { margin-top: 1rem; @@ -36,6 +16,9 @@ } .callToolPanel { + display: flex; + flex-direction: column; + gap: 1rem; max-width: 480px; form { @@ -54,24 +37,18 @@ select, textarea { padding: 0.5rem; - border: 1px solid var(--color-border); + border: 1px solid #ccc; border-radius: 4px; + font-family: monospace; font-size: inherit; - background-color: var(--color-bg); - color: var(--color-text); } - .toolSelect { - font-family: monospace; - } - - .toolInput { + textarea { min-height: 6rem; - font-family: monospace; resize: vertical; &[aria-invalid="true"] { - background-color: light-dark(#fee2e2, #7f1d1d); + background-color: #fdd; } } @@ -81,14 +58,14 @@ padding: 0.75rem 1.5rem; border: none; border-radius: 4px; - background-color: var(--color-primary); + background-color: #1e3a5f; font-size: inherit; font-weight: 600; color: white; cursor: pointer; &:hover:not(:disabled) { - background-color: var(--color-primary-hover); + background-color: #2d4a7c; } &:disabled { @@ -100,8 +77,7 @@ .toolCallInfoPanel { display: flex; - flex-direction: column; - gap: 0.5rem; + gap: 1rem; animation: slideDown 0.3s ease-out; } @@ -109,173 +85,49 @@ from { opacity: 0; transform: translateY(-12px); } } -.appHeader { +.inputInfoPanel { display: flex; - align-items: center; + flex: 3; + flex-direction: column; gap: 0.5rem; - margin-bottom: 0.5rem; - font-size: 1.25rem; - font-weight: 600; - position: relative; + min-width: 0; .toolName { + margin: 0; font-family: monospace; + font-size: 1.5rem; } +} - .closeButton { - position: absolute; - top: 0; - right: 0; - width: 1.5rem; - height: 1.5rem; - padding: 0; - border: none; - border-radius: 4px; - background: var(--color-border); - font-size: 1.25rem; - line-height: 1; - color: var(--color-text-secondary); - cursor: pointer; +.outputInfoPanel { + flex: 4; + min-width: 0; +} - &:hover { - background: var(--color-bg-secondary); - color: var(--color-text); - } - } +.jsonBlock { + flex-grow: 1; + min-height: 0; + margin: 0; + padding: 1rem; + border-radius: 4px; + background-color: #f5f5f5; + overflow: auto; } .appIframePanel { - min-height: 100px; + min-height: 200px; iframe { width: 100%; - height: 400px; + height: 600px; box-sizing: border-box; - border: 3px dashed var(--color-border); + border: 3px dashed #888; border-radius: 4px; } - - &.fullscreen { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 1000; - margin: 0; - padding: 1rem; - max-width: none; - background: var(--color-bg); - border: none; - border-radius: 0; - display: flex; - flex-direction: column; - - /* Hide collapsible panels in fullscreen */ - .collapsiblePanel { - display: none; - } - - iframe { - flex: 1; - height: 100%; - border: none; - border-radius: 0; - } - } -} - -.appToolbar { - display: flex; - align-items: center; - gap: 0.5rem; - margin-bottom: 0.5rem; - - .collapsiblePanel { - flex: 1; - margin: 0; - } -} - -.fullscreenButton { - width: 32px; - height: 32px; - padding: 0; - border: 1px solid var(--color-border); - border-radius: 4px; - background: var(--color-bg-secondary); - color: var(--color-text); - font-size: 1.25rem; - cursor: pointer; - - &:hover { - background: var(--color-bg); - } -} - -.collapsiblePanel { - margin: 0.5rem 0; - padding: 0.5rem 0.75rem; - border: 1px solid var(--color-border); - border-radius: 4px; - background-color: var(--color-bg-secondary); - font-size: 0.875rem; - cursor: pointer; - transition: background-color 0.15s; - - &:hover { - background-color: var(--color-bg); - } -} - -.collapsibleHeader { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.collapsibleLabel { - font-weight: 600; - color: var(--color-text); -} - -.collapsibleSize { - color: var(--color-text-secondary); - font-size: 0.75rem; -} - -.collapsibleToggle { - margin-left: auto; - color: var(--color-text-secondary); - font-size: 0.75rem; -} - -.collapsiblePreview { - margin-top: 0.25rem; - color: var(--color-text-secondary); - font-family: monospace; - font-size: 0.8rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.collapsibleFull { - margin: 0.5rem 0 0; - padding: 0.5rem; - border-radius: 4px; - background-color: var(--color-bg); - color: var(--color-text); - font-family: monospace; - font-size: 0.8rem; - white-space: pre-wrap; - word-break: break-word; - max-height: 200px; - overflow: auto; } .error { padding: 1.5rem; - background-color: var(--color-bg-secondary); - color: light-dark(#dc2626, #f87171); + background-color: #ddd; + color: #d00; } diff --git a/examples/basic-host/src/index.tsx b/examples/basic-host/src/index.tsx index 00c0d52..5a346fc 100644 --- a/examples/basic-host/src/index.tsx +++ b/examples/basic-host/src/index.tsx @@ -1,156 +1,120 @@ -import { getToolUiResourceUri, McpUiToolMetaSchema } from "@modelcontextprotocol/ext-apps/app-bridge"; -import type { Tool } from "@modelcontextprotocol/client"; import { Component, type ErrorInfo, type ReactNode, StrictMode, Suspense, use, useEffect, useMemo, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; -import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo, type ModelContext, type AppMessage } from "./implementation"; -import { getTheme, toggleTheme, onThemeChange, type Theme } from "./theme"; +import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo } from "./implementation"; import styles from "./index.module.css"; -/** - * Check if a tool is visible to the model (not app-only). - * Tools with `visibility: ["app"]` should not be shown in tool lists. - */ -function isToolVisibleToModel(tool: { _meta?: Record }): boolean { - const result = McpUiToolMetaSchema.safeParse(tool._meta?.ui); - if (!result.success) return true; // default: visible to model - const visibility = result.data.visibility; - if (!visibility) return true; // default: visible to model - return visibility.includes("model"); + +// Available MCP servers - using ports 3101+ to avoid conflicts with common dev ports +const SERVERS = [ + { name: "Basic React", port: 3101 }, + { name: "Vanilla JS", port: 3102 }, + { name: "Budget Allocator", port: 3103 }, + { name: "Cohort Heatmap", port: 3104 }, + { name: "Customer Segmentation", port: 3105 }, + { name: "Scenario Modeler", port: 3106 }, + { name: "System Monitor", port: 3107 }, + { name: "Three.js", port: 3109 }, +] as const; + +function serverUrl(port: number): string { + return `http://localhost:${port}/mcp`; } -/** Compare tools: UI-enabled first, then alphabetically by name. */ -function compareTools(a: Tool, b: Tool): number { - const aHasUi = !!getToolUiResourceUri(a); - const bHasUi = !!getToolUiResourceUri(b); - if (aHasUi && !bHasUi) return -1; - if (!aHasUi && bHasUi) return 1; - return a.name.localeCompare(b.name); -} +// Cache server connections to avoid reconnecting when switching between servers +const serverInfoCache = new Map>(); -/** - * Extract default values from a tool's JSON Schema inputSchema. - * Returns a formatted JSON string with defaults, or "{}" if none found. - */ -function getToolDefaults(tool: Tool | undefined): string { - if (!tool?.inputSchema?.properties) return "{}"; - - const defaults: Record = {}; - for (const [key, prop] of Object.entries(tool.inputSchema.properties)) { - if (prop && typeof prop === "object" && "default" in prop) { - defaults[key] = prop.default; - } +function getServerInfo(port: number): Promise { + let promise = serverInfoCache.get(port); + if (!promise) { + promise = connectToServer(new URL(serverUrl(port))); + // Remove from cache on failure so retry is possible + promise.catch(() => serverInfoCache.delete(port)); + serverInfoCache.set(port, promise); } - - return Object.keys(defaults).length > 0 - ? JSON.stringify(defaults, null, 2) - : "{}"; + return promise; } -// Host passes serversPromise to CallToolPanel -interface HostProps { - serversPromise: Promise; +// Wrapper to track server name with each tool call +interface ToolCallEntry { + serverName: string; + info: ToolCallInfo; } -type ToolCallEntry = ToolCallInfo & { id: number }; -let nextToolCallId = 0; - -// Parse URL query params for debugging: ?server=name&tool=name&call=true&theme=hide -function getQueryParams() { - const params = new URLSearchParams(window.location.search); - return { - server: params.get("server"), - tool: params.get("tool"), - call: params.get("call") === "true", - hideThemeToggle: params.get("theme") === "hide", - }; -} - -/** - * Theme toggle button with light/dark icons. - */ -function ThemeToggle() { - const [theme, setTheme] = useState(getTheme); - - useEffect(() => { - return onThemeChange(setTheme); - }, []); - - return ( - - ); -} - - -function Host({ serversPromise }: HostProps) { +// Host just manages tool call results - no server dependency +function Host() { const [toolCalls, setToolCalls] = useState([]); - const [destroyingIds, setDestroyingIds] = useState>(new Set()); - const queryParams = useMemo(() => getQueryParams(), []); - - const requestClose = (id: number) => { - setDestroyingIds((s) => new Set(s).add(id)); - }; - - const completeClose = (id: number) => { - setDestroyingIds((s) => { - const next = new Set(s); - next.delete(id); - return next; - }); - setToolCalls((calls) => calls.filter((c) => c.id !== id)); - }; return ( <> - {!queryParams.hideThemeToggle && } - {toolCalls.map((info) => ( - requestClose(info.id)} - onCloseComplete={() => completeClose(info.id)} - /> + {toolCalls.map((entry, i) => ( + ))} setToolCalls([...toolCalls, { ...info, id: nextToolCallId++ }])} - initialServer={queryParams.server} - initialTool={queryParams.tool} - autoCall={queryParams.call} + addToolCall={(serverName, info) => setToolCalls([...toolCalls, { serverName, info }])} /> ); } -// CallToolPanel renders the unified form with Suspense around ServerSelect +// CallToolPanel includes server selection with its own Suspense boundary interface CallToolPanelProps { - serversPromise: Promise; - addToolCall: (info: ToolCallInfo) => void; - initialServer?: string | null; - initialTool?: string | null; - autoCall?: boolean; + addToolCall: (serverName: string, info: ToolCallInfo) => void; } -function CallToolPanel({ serversPromise, addToolCall, initialServer, initialTool, autoCall }: CallToolPanelProps) { - const [selectedServer, setSelectedServer] = useState(null); - const [selectedTool, setSelectedTool] = useState(""); - const [inputJson, setInputJson] = useState("{}"); - const [hasAutoCalledRef] = useState({ called: false }); +function CallToolPanel({ addToolCall }: CallToolPanelProps) { + const [selectedServer, setSelectedServer] = useState(SERVERS[0]); + const [serverInfoPromise, setServerInfoPromise] = useState( + () => getServerInfo(selectedServer.port) + ); - // Filter out app-only tools, prioritize tools with UIs - const toolNames = selectedServer - ? Array.from(selectedServer.tools.values()) - .filter((tool) => isToolVisibleToModel(tool)) - .sort(compareTools) - .map((tool) => tool.name) - : []; + const handleServerChange = (port: number) => { + const server = SERVERS.find(s => s.port === port) ?? SERVERS[0]; + setSelectedServer(server); + setServerInfoPromise(getServerInfo(port)); + }; + + return ( +
+ + + Connecting to {serverUrl(selectedServer.port)}...

}> + +
+
+
+ ); +} + + +// ToolCallForm renders inside Suspense - needs serverInfo for tool list +interface ToolCallFormProps { + serverName: string; + serverInfoPromise: Promise; + addToolCall: (serverName: string, info: ToolCallInfo) => void; +} +function ToolCallForm({ serverName, serverInfoPromise, addToolCall }: ToolCallFormProps) { + const serverInfo = use(serverInfoPromise); + const toolNames = Array.from(serverInfo.tools.keys()); + const [selectedTool, setSelectedTool] = useState(toolNames[0] ?? ""); + const [inputJson, setInputJson] = useState("{}"); const isValidJson = useMemo(() => { try { @@ -161,373 +125,99 @@ function CallToolPanel({ serversPromise, addToolCall, initialServer, initialTool } }, [inputJson]); - const handleServerSelect = (server: ServerInfo, preferredTool?: string) => { - setSelectedServer(server); - // Filter out app-only tools, prioritize tools with UIs - const visibleTools = Array.from(server.tools.values()) - .filter((tool) => isToolVisibleToModel(tool)) - .sort(compareTools); - - // Use preferred tool if it exists and is visible, otherwise first visible tool - const targetTool = preferredTool && visibleTools.some(t => t.name === preferredTool) - ? preferredTool - : visibleTools[0]?.name ?? ""; - - setSelectedTool(targetTool); - // Set input JSON to tool defaults (if any) - setInputJson(getToolDefaults(server.tools.get(targetTool))); - }; - - const handleToolSelect = (toolName: string) => { - setSelectedTool(toolName); - // Set input JSON to tool defaults (if any) - setInputJson(getToolDefaults(selectedServer?.tools.get(toolName))); - }; - - // Submit with optional override for server/tool (used by auto-call) - const handleSubmit = (overrideServer?: ServerInfo, overrideTool?: string) => { - const server = overrideServer ?? selectedServer; - const tool = overrideTool ?? selectedTool; - if (!server) return; - - const toolCallInfo = callTool(server, tool, JSON.parse(inputJson)); - addToolCall(toolCallInfo); - - // Update URL for easy refresh/sharing (without triggering navigation) - const url = new URL(window.location.href); - url.searchParams.set("server", server.name); - url.searchParams.set("tool", tool); - url.searchParams.set("call", "true"); // Auto-call on refresh - history.replaceState(null, "", url.toString()); + const handleSubmit = () => { + const toolCallInfo = callTool(serverInfo, selectedTool, JSON.parse(inputJson)); + addToolCall(serverName, toolCallInfo); }; return ( -
-
{ e.preventDefault(); handleSubmit(); }}> - - -
- -
- - -
- -
- - -
- - - diff --git a/examples/basic-server-svelte/src/global.css b/examples/basic-server-svelte/src/global.css deleted file mode 100644 index 801291f..0000000 --- a/examples/basic-server-svelte/src/global.css +++ /dev/null @@ -1,92 +0,0 @@ -:root { - color-scheme: light dark; - - /* - * Fallbacks for host style variables used by this app. - * The host may provide these (and many more) via the host context. - */ - --color-text-primary: light-dark(#1f2937, #f3f4f6); - --color-text-inverse: light-dark(#f3f4f6, #1f2937); - --color-text-info: light-dark(#1d4ed8, #60a5fa); - --color-background-primary: light-dark(#ffffff, #1a1a1a); - --color-background-inverse: light-dark(#1a1a1a, #ffffff); - --color-background-info: light-dark(#eff6ff, #1e3a5f); - --color-ring-primary: light-dark(#3b82f6, #60a5fa); - --border-radius-md: 6px; - --border-width-regular: 1px; - --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; - --font-weight-normal: 400; - --font-weight-bold: 700; - --font-text-md-size: 1rem; - --font-text-md-line-height: 1.5; - --font-heading-3xl-size: 2.25rem; - --font-heading-3xl-line-height: 1.1; - --font-heading-2xl-size: 1.875rem; - --font-heading-2xl-line-height: 1.2; - --font-heading-xl-size: 1.5rem; - --font-heading-xl-line-height: 1.25; - --font-heading-lg-size: 1.25rem; - --font-heading-lg-line-height: 1.3; - --font-heading-md-size: 1rem; - --font-heading-md-line-height: 1.4; - --font-heading-sm-size: 0.875rem; - --font-heading-sm-line-height: 1.4; - - /* Spacing derived from host typography */ - --spacing-unit: var(--font-text-md-size); - --spacing-xs: calc(var(--spacing-unit) * 0.25); - --spacing-sm: calc(var(--spacing-unit) * 0.5); - --spacing-md: var(--spacing-unit); - --spacing-lg: calc(var(--spacing-unit) * 1.5); - - /* App accent color (customize for your brand) */ - --color-accent: #2563eb; - --color-text-on-accent: #ffffff; -} - -* { - box-sizing: border-box; -} - -html, body { - font-family: var(--font-sans); - font-size: var(--font-text-md-size); - font-weight: var(--font-weight-normal); - line-height: var(--font-text-md-line-height); - color: var(--color-text-primary); -} - -h1 { - font-size: var(--font-heading-3xl-size); - line-height: var(--font-heading-3xl-line-height); -} -h2 { - font-size: var(--font-heading-2xl-size); - line-height: var(--font-heading-2xl-line-height); -} -h3 { - font-size: var(--font-heading-xl-size); - line-height: var(--font-heading-xl-line-height); -} -h4 { - font-size: var(--font-heading-lg-size); - line-height: var(--font-heading-lg-line-height); -} -h5 { - font-size: var(--font-heading-md-size); - line-height: var(--font-heading-md-line-height); -} -h6 { - font-size: var(--font-heading-sm-size); - line-height: var(--font-heading-sm-line-height); -} - -code, pre, kbd { - font-family: var(--font-mono); - font-size: 1em; -} - -b, strong { - font-weight: var(--font-weight-bold); -} diff --git a/examples/basic-server-svelte/src/mcp-app.ts b/examples/basic-server-svelte/src/mcp-app.ts deleted file mode 100644 index e57b774..0000000 --- a/examples/basic-server-svelte/src/mcp-app.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { mount } from "svelte"; -import App from "./App.svelte"; -import "./global.css"; - -mount(App, { target: document.getElementById("app")! }); diff --git a/examples/basic-server-svelte/svelte.config.js b/examples/basic-server-svelte/svelte.config.js deleted file mode 100644 index d0e6448..0000000 --- a/examples/basic-server-svelte/svelte.config.js +++ /dev/null @@ -1,5 +0,0 @@ -import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; - -export default { - preprocess: vitePreprocess(), -}; diff --git a/examples/basic-server-svelte/tsconfig.json b/examples/basic-server-svelte/tsconfig.json deleted file mode 100644 index 4fd3cd7..0000000 --- a/examples/basic-server-svelte/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "types": ["node", "vite/client"], - "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", "*.d.ts"] -} diff --git a/examples/basic-server-svelte/tsconfig.server.json b/examples/basic-server-svelte/tsconfig.server.json deleted file mode 100644 index 27eb28f..0000000 --- a/examples/basic-server-svelte/tsconfig.server.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "types": ["node"], - "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/examples/basic-server-svelte/vite.config.ts b/examples/basic-server-svelte/vite.config.ts deleted file mode 100644 index 84fcdbd..0000000 --- a/examples/basic-server-svelte/vite.config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { defineConfig } from "vite"; -import { svelte } from "@sveltejs/vite-plugin-svelte"; -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: [svelte(), viteSingleFile()], - build: { - sourcemap: isDevelopment ? "inline" : undefined, - cssMinify: !isDevelopment, - minify: !isDevelopment, - - rollupOptions: { - input: INPUT, - }, - outDir: "dist", - emptyOutDir: false, - }, -}); diff --git a/examples/basic-server-vanillajs/.gitignore b/examples/basic-server-vanillajs/.gitignore deleted file mode 100644 index b947077..0000000 --- a/examples/basic-server-vanillajs/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -dist/ diff --git a/examples/basic-server-vanillajs/README.md b/examples/basic-server-vanillajs/README.md index 6445d8c..8d22726 100644 --- a/examples/basic-server-vanillajs/README.md +++ b/examples/basic-server-vanillajs/README.md @@ -5,51 +5,11 @@ An MCP App example with a vanilla JavaScript UI (no framework). > [!TIP] > Looking for a React-based example? See [`basic-server-react`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react)! -## MCP Client Configuration - -Add to your MCP client configuration (stdio transport): - -```json -{ - "mcpServers": { - "basic-vanillajs": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-basic-vanillajs", - "--stdio" - ] - } - } -} -``` - -### Local Development - -To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path): - -```json -{ - "mcpServers": { - "basic-vanillajs": { - "command": "bash", - "args": [ - "-c", - "cd ~/code/ext-apps/examples/basic-server-vanillajs && npm run build >&2 && node dist/index.js --stdio" - ] - } - } -} -``` - ## Overview - Tool registration with a linked UI resource -- Vanilla JS UI using the [`App`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html) class directly -- App communication APIs: [`callServerTool`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#callservertool), [`sendMessage`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendmessage), [`sendLog`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendlog), [`openLink`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#openlink) -- Theme integration via [`applyDocumentTheme()`](https://apps.extensions.modelcontextprotocol.io/api/functions/app.applyDocumentTheme.html), [`applyHostStyleVariables()`](https://apps.extensions.modelcontextprotocol.io/api/functions/app.applyHostStyleVariables.html), and [`applyHostFonts()`](https://apps.extensions.modelcontextprotocol.io/api/functions/app.applyHostFonts.html) +- Vanilla JS UI using the [`App`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html) class directly +- App communication APIs: [`callServerTool`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#callservertool), [`sendMessage`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendmessage), [`sendLog`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendlog), [`sendOpenLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendopenlink) ## Key Files @@ -69,6 +29,28 @@ npm run dev 2. When the tool is invoked, the Host renders the UI from the resource. 3. The UI uses the MCP App SDK API to communicate with the host and call server tools. -## Build System +## Vanilla JS Pattern -This example bundles into a single HTML file using Vite with `vite-plugin-singlefile` โ€” see [`vite.config.ts`](vite.config.ts). This allows all UI content to be served as a single MCP resource. Alternatively, MCP apps can load external resources by defining [`_meta.ui.csp.resourceDomains`](https://apps.extensions.modelcontextprotocol.io/api/interfaces/app.McpUiResourceCsp.html#resourcedomains) in the UI resource metadata. +```typescript +import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps"; + +// Get element references from static HTML +const button = document.getElementById("my-button")!; + +// Create app instance +const app = new App({ name: "My App", version: "1.0.0" }); + +// Register handlers BEFORE connecting +app.ontoolresult = (result) => { + /* handle result */ +}; +app.onerror = console.error; + +// Add event listeners +button.addEventListener("click", () => { + /* ... */ +}); + +// Connect to host +app.connect(new PostMessageTransport(window.parent)); +``` diff --git a/examples/basic-server-vanillajs/main.ts b/examples/basic-server-vanillajs/main.ts deleted file mode 100644 index 4de7331..0000000 --- a/examples/basic-server-vanillajs/main.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Entry point for running the MCP server. - * Run with: npx mcp-server-basic-vanillajs - * Or: node dist/index.js [--stdio] - */ - -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; -import { createMcpExpressApp } from "@modelcontextprotocol/express"; -import type { McpServer } from "@modelcontextprotocol/server"; -import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node"; -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. - * - * @param createServer - Factory function that creates a new McpServer instance per request. - */ -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 NodeStreamableHTTPServerTransport({ - 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. - * - * @param createServer - Factory function that creates a new McpServer instance. - */ -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/examples/basic-server-vanillajs/mcp-app.html b/examples/basic-server-vanillajs/mcp-app.html index 5b64280..1a88d60 100644 --- a/examples/basic-server-vanillajs/mcp-app.html +++ b/examples/basic-server-vanillajs/mcp-app.html @@ -3,7 +3,6 @@ - Get Time App diff --git a/examples/basic-server-vanillajs/package.json b/examples/basic-server-vanillajs/package.json index ebfebbb..3c1b7ff 100644 --- a/examples/basic-server-vanillajs/package.json +++ b/examples/basic-server-vanillajs/package.json @@ -1,56 +1,29 @@ { - "name": "@modelcontextprotocol/server-basic-vanillajs", - "version": "2.0.0", + "name": "basic-server-vanillajs", + "version": "1.0.0", + "private": true, "type": "module", - "description": "Basic MCP App Server example using vanilla JavaScript", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", - "directory": "examples/basic-server-vanillajs" - }, - "license": "MIT", - "main": "dist/server.js", - "files": [ - "dist" - ], "scripts": { - "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"", - "watch": "cross-env INPUT=mcp-app.html vite build --watch", - "serve": "bun --watch main.ts", - "serve:stdio": "bun main.ts --stdio", - "start": "cross-env NODE_ENV=development npm run build && npm run serve", - "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio", - "dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"", - "prepublishOnly": "npm run build" + "build": "INPUT=mcp-app.html vite build", + "watch": "INPUT=mcp-app.html vite build --watch", + "serve": "bun server.ts", + "start": "NODE_ENV=development npm run build && npm run serve", + "dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve'" }, "dependencies": { - "@modelcontextprotocol/client": "2.0.0", - "@modelcontextprotocol/express": "2.0.0", - "@modelcontextprotocol/ext-apps": "^2.0.0", - "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", - "cors": "^2.8.5", - "express": "^5.1.0", - "zod": "^4.2.0" + "@modelcontextprotocol/ext-apps": "../..", + "@modelcontextprotocol/sdk": "^1.22.0", + "zod": "^3.25.0" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.0", - "@types/node": "22.10.0", + "@types/node": "^22.0.0", "concurrently": "^9.2.1", - "cross-env": "^10.1.0", + "cors": "^2.8.5", + "express": "^5.1.0", "typescript": "^5.9.3", "vite": "^6.0.0", "vite-plugin-singlefile": "^2.3.0" - }, - "types": "dist/server.d.ts", - "exports": { - ".": { - "types": "./dist/server.d.ts", - "default": "./dist/server.js" - } - }, - "bin": { - "mcp-server-basic-vanillajs": "dist/index.js" } } diff --git a/examples/basic-server-vanillajs/server.ts b/examples/basic-server-vanillajs/server.ts index 693d861..eff3120 100644 --- a/examples/basic-server-vanillajs/server.ts +++ b/examples/basic-server-vanillajs/server.ts @@ -1,67 +1,105 @@ -import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; -import { - McpServer, - type CallToolResult, - type ReadResourceResult, -} from "@modelcontextprotocol/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; +import cors from "cors"; +import express, { type Request, type Response } from "express"; import fs from "node:fs/promises"; import path from "node:path"; -import { z } from "zod"; -// 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; +import { RESOURCE_URI_META_KEY } from "../../dist/src/app"; -/** - * Creates a new MCP server instance with tools and resources registered. - */ -export function createServer(): McpServer { - const server = new McpServer({ - name: "Basic MCP App Server (Vanilla JS)", - version: "1.0.0", - }); +const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3001; +const DIST_DIR = path.join(import.meta.dirname, "dist"); - // Two-part registration: tool + resource, tied together by the resource URI. + +const server = new McpServer({ + name: "MCP App Server", + version: "1.0.0", +}); + + +// MCP Apps require two-part registration: a tool (what the LLM calls) and a +// resource (the UI it renders). The `_meta` field on the tool links to the +// resource URI, telling hosts which UI to display when the tool executes. +{ 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, + server.registerTool( "get-time", { title: "Get Time", description: "Returns the current server time as an ISO 8601 string.", - inputSchema: z.object({}), - outputSchema: z.object({ - time: z.string(), - }), - _meta: { ui: { resourceUri } }, // Links this tool to its UI resource + inputSchema: {}, + _meta: { [RESOURCE_URI_META_KEY]: resourceUri }, }, async (): Promise => { const time = new Date().toISOString(); return { - content: [{ type: "text", text: time }], - structuredContent: { time }, + content: [{ type: "text", text: JSON.stringify({ time }) }], }; }, ); - // Register the resource, which returns the bundled HTML/JavaScript for the UI. - registerAppResource(server, + server.registerResource( 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 }, + // Per the MCP App specification, "text/html;profile=mcp-app" signals + // to the Host that this resource is indeed for an MCP App UI. + { uri: resourceUri, mimeType: "text/html;profile=mcp-app", text: html }, ], }; }, ); - - return server; } + + +const app = express(); +app.use(cors()); +app.use(express.json()); + +app.post("/mcp", async (req: Request, res: Response) => { + try { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + res.on("close", () => { transport.close(); }); + + await server.connect(transport); + + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("Error handling MCP request:", 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("Error starting server:", err); + process.exit(1); + } + console.log(`Server listening on http://localhost:${PORT}/mcp`); +}); + +function shutdown() { + console.log("\nShutting down..."); + httpServer.close(() => { + console.log("Server closed"); + process.exit(0); + }); +} + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/examples/basic-server-vanillajs/src/global.css b/examples/basic-server-vanillajs/src/global.css index 801291f..97cda44 100644 --- a/examples/basic-server-vanillajs/src/global.css +++ b/examples/basic-server-vanillajs/src/global.css @@ -1,92 +1,12 @@ -:root { - color-scheme: light dark; - - /* - * Fallbacks for host style variables used by this app. - * The host may provide these (and many more) via the host context. - */ - --color-text-primary: light-dark(#1f2937, #f3f4f6); - --color-text-inverse: light-dark(#f3f4f6, #1f2937); - --color-text-info: light-dark(#1d4ed8, #60a5fa); - --color-background-primary: light-dark(#ffffff, #1a1a1a); - --color-background-inverse: light-dark(#1a1a1a, #ffffff); - --color-background-info: light-dark(#eff6ff, #1e3a5f); - --color-ring-primary: light-dark(#3b82f6, #60a5fa); - --border-radius-md: 6px; - --border-width-regular: 1px; - --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; - --font-weight-normal: 400; - --font-weight-bold: 700; - --font-text-md-size: 1rem; - --font-text-md-line-height: 1.5; - --font-heading-3xl-size: 2.25rem; - --font-heading-3xl-line-height: 1.1; - --font-heading-2xl-size: 1.875rem; - --font-heading-2xl-line-height: 1.2; - --font-heading-xl-size: 1.5rem; - --font-heading-xl-line-height: 1.25; - --font-heading-lg-size: 1.25rem; - --font-heading-lg-line-height: 1.3; - --font-heading-md-size: 1rem; - --font-heading-md-line-height: 1.4; - --font-heading-sm-size: 0.875rem; - --font-heading-sm-line-height: 1.4; - - /* Spacing derived from host typography */ - --spacing-unit: var(--font-text-md-size); - --spacing-xs: calc(var(--spacing-unit) * 0.25); - --spacing-sm: calc(var(--spacing-unit) * 0.5); - --spacing-md: var(--spacing-unit); - --spacing-lg: calc(var(--spacing-unit) * 1.5); - - /* App accent color (customize for your brand) */ - --color-accent: #2563eb; - --color-text-on-accent: #ffffff; -} - * { box-sizing: border-box; } html, body { - font-family: var(--font-sans); - font-size: var(--font-text-md-size); - font-weight: var(--font-weight-normal); - line-height: var(--font-text-md-line-height); - color: var(--color-text-primary); + font-family: system-ui, -apple-system, sans-serif; + font-size: 1rem; } -h1 { - font-size: var(--font-heading-3xl-size); - line-height: var(--font-heading-3xl-line-height); -} -h2 { - font-size: var(--font-heading-2xl-size); - line-height: var(--font-heading-2xl-line-height); -} -h3 { - font-size: var(--font-heading-xl-size); - line-height: var(--font-heading-xl-line-height); -} -h4 { - font-size: var(--font-heading-lg-size); - line-height: var(--font-heading-lg-line-height); -} -h5 { - font-size: var(--font-heading-md-size); - line-height: var(--font-heading-md-line-height); -} -h6 { - font-size: var(--font-heading-sm-size); - line-height: var(--font-heading-sm-line-height); -} - -code, pre, kbd { - font-family: var(--font-mono); +code { font-size: 1em; } - -b, strong { - font-weight: var(--font-weight-bold); -} diff --git a/examples/basic-server-vanillajs/src/mcp-app.css b/examples/basic-server-vanillajs/src/mcp-app.css index b23bd5e..49caa84 100644 --- a/examples/basic-server-vanillajs/src/mcp-app.css +++ b/examples/basic-server-vanillajs/src/mcp-app.css @@ -1,7 +1,9 @@ .main { - width: 100%; - max-width: 425px; - box-sizing: border-box; + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + --color-notice-bg: #eff6ff; + + min-width: 425px; > * { margin-top: 0; @@ -9,7 +11,7 @@ } > * + * { - margin-top: var(--spacing-lg); + margin-top: 1.5rem; } } @@ -21,59 +23,41 @@ } > * + * { - margin-top: var(--spacing-sm); - } - - /* Server time row: flex layout for consistent mask width in E2E tests */ - > p { - display: flex; - align-items: baseline; - gap: var(--spacing-xs); + margin-top: 0.5rem; } /* Consistent font for form inputs (inherits from global.css) */ textarea, input { - display: block; font-family: inherit; font-size: inherit; } button { - padding: var(--spacing-sm) var(--spacing-md); + padding: 0.5rem 1rem; border: none; - border-radius: var(--border-radius-md); - color: var(--color-text-on-accent); - font-weight: var(--font-weight-bold); - background-color: var(--color-accent); + border-radius: 6px; + color: white; + font-weight: bold; + background-color: var(--color-primary); cursor: pointer; - &:hover { - background-color: color-mix(in srgb, var(--color-accent) 85%, var(--color-background-inverse)); - } - + &:hover, &:focus-visible { - outline: calc(var(--border-width-regular) * 2) solid var(--color-ring-primary); - outline-offset: var(--border-width-regular); + background-color: var(--color-primary-hover); } } } .notice { - padding: var(--spacing-sm) var(--spacing-md); - color: var(--color-text-info); + padding: 0.5rem 0.75rem; + color: var(--color-primary); text-align: center; font-style: italic; - background-color: var(--color-background-info); + background-color: var(--color-notice-bg); &::before { content: "โ„น๏ธ "; font-style: normal; } } - -/* Server time fills remaining width for consistent E2E screenshot masking */ -#server-time { - flex: 1; - min-width: 0; -} diff --git a/examples/basic-server-vanillajs/src/mcp-app.ts b/examples/basic-server-vanillajs/src/mcp-app.ts index 6b5084a..f93dfee 100644 --- a/examples/basic-server-vanillajs/src/mcp-app.ts +++ b/examples/basic-server-vanillajs/src/mcp-app.ts @@ -1,25 +1,30 @@ /** * @file App that demonstrates a few features using MCP Apps SDK with vanilla JS. */ -import { - App, - applyDocumentTheme, - applyHostFonts, - applyHostStyleVariables, - type McpUiHostContext, -} from "@modelcontextprotocol/ext-apps"; -import type { CallToolResult } from "@modelcontextprotocol/client"; +import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import "./global.css"; import "./mcp-app.css"; +const log = { + info: console.log.bind(console, "[APP]"), + warn: console.warn.bind(console, "[APP]"), + error: console.error.bind(console, "[APP]"), +}; + + function extractTime(result: CallToolResult): string { - const { time } = (result.structuredContent as { time?: string }) ?? {}; - return time ?? "[ERROR]"; + const text = result.content! + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + const { time } = JSON.parse(text) as { time: string }; + return time; } -const mainEl = document.querySelector(".main") as HTMLElement; +// Get element references const serverTimeEl = document.getElementById("server-time")!; const getTimeBtn = document.getElementById("get-time-btn")!; const messageText = document.getElementById("message-text") as HTMLTextAreaElement; @@ -30,61 +35,32 @@ const linkUrl = document.getElementById("link-url") as HTMLInputElement; const openLinkBtn = document.getElementById("open-link-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 +// Create app instance const app = new App({ name: "Get Time App", version: "1.0.0" }); -// 2. Register handlers BEFORE connecting -app.onteardown = async () => { - console.info("App is being torn down"); - return {}; -}; - +// Register handlers BEFORE connecting app.ontoolinput = (params) => { - console.info("Received tool call input:", params); + log.info("Received tool call input:", params); }; app.ontoolresult = (result) => { - console.info("Received tool call result:", result); + log.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; +app.onerror = log.error; +// Add event listeners getTimeBtn.addEventListener("click", async () => { try { - console.info("Calling get-time tool..."); + log.info("Calling get-time tool..."); const result = await app.callServerTool({ name: "get-time", arguments: {} }); - console.info("get-time result:", result); + log.info("get-time result:", result); serverTimeEl.textContent = extractTime(result); } catch (e) { - console.error(e); + log.error(e); serverTimeEl.textContent = "[ERROR]"; } }); @@ -92,33 +68,28 @@ getTimeBtn.addEventListener("click", async () => { sendMessageBtn.addEventListener("click", async () => { const signal = AbortSignal.timeout(5000); try { - console.info("Sending message text to Host:", messageText.value); + log.info("Sending message text to Host:", messageText.value); const { isError } = await app.sendMessage( { role: "user", content: [{ type: "text", text: messageText.value }] }, { signal }, ); - console.info("Message", isError ? "rejected" : "accepted"); + log.info("Message", isError ? "rejected" : "accepted"); } catch (e) { - console.error("Message send error:", signal.aborted ? "timed out" : e); + log.error("Message send error:", signal.aborted ? "timed out" : e); } }); sendLogBtn.addEventListener("click", async () => { - console.info("Sending log text to Host:", logText.value); + log.info("Sending log text to Host:", logText.value); await app.sendLog({ level: "info", data: logText.value }); }); openLinkBtn.addEventListener("click", async () => { - console.info("Sending open link request to Host:", linkUrl.value); - const { isError } = await app.openLink({ url: linkUrl.value }); - console.info("Open link request", isError ? "rejected" : "accepted"); + log.info("Sending open link request to Host:", linkUrl.value); + const { isError } = await app.sendOpenLink({ url: linkUrl.value }); + log.info("Open link request", isError ? "rejected" : "accepted"); }); -// 3. Connect to host -app.connect().then(() => { - const ctx = app.getHostContext(); - if (ctx) { - handleHostContextChanged(ctx); - } -}); +// Connect to host +app.connect(new PostMessageTransport(window.parent)); diff --git a/examples/basic-server-vanillajs/tsconfig.json b/examples/basic-server-vanillajs/tsconfig.json index b028ff5..535267b 100644 --- a/examples/basic-server-vanillajs/tsconfig.json +++ b/examples/basic-server-vanillajs/tsconfig.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "types": ["node", "vite/client"], "target": "ESNext", "lib": ["ESNext", "DOM", "DOM.Iterable"], "module": "ESNext", diff --git a/examples/basic-server-vanillajs/tsconfig.server.json b/examples/basic-server-vanillajs/tsconfig.server.json deleted file mode 100644 index 27eb28f..0000000 --- a/examples/basic-server-vanillajs/tsconfig.server.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "types": ["node"], - "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/examples/basic-server-vue/.gitignore b/examples/basic-server-vue/.gitignore deleted file mode 100644 index b947077..0000000 --- a/examples/basic-server-vue/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -dist/ diff --git a/examples/basic-server-vue/README.md b/examples/basic-server-vue/README.md deleted file mode 100644 index c484f5b..0000000 --- a/examples/basic-server-vue/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# Example: Basic Server (Vue) - -An MCP App example with a Vue 3 UI using the Composition API. - -> [!TIP] -> Looking for a vanilla JavaScript example? See [`basic-server-vanillajs`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs)! - -## MCP Client Configuration - -Add to your MCP client configuration (stdio transport): - -```json -{ - "mcpServers": { - "basic-vue": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-basic-vue", - "--stdio" - ] - } - } -} -``` - -### Local Development - -To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path): - -```json -{ - "mcpServers": { - "basic-vue": { - "command": "bash", - "args": [ - "-c", - "cd ~/code/ext-apps/examples/basic-server-vue && npm run build >&2 && node dist/index.js --stdio" - ] - } - } -} -``` - -## Overview - -- Tool registration with a linked UI resource -- Vue 3 UI using the [`App`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html) class -- App communication APIs: [`callServerTool`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#callservertool), [`sendMessage`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendmessage), [`sendLog`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendlog), [`openLink`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#openlink) - -## Key Files - -- [`server.ts`](server.ts) - MCP server with tool and resource registration -- [`mcp-app.html`](mcp-app.html) / [`src/App.vue`](src/App.vue) - Vue 3 UI using `App` class - -## Getting Started - -```bash -npm install -npm run dev -``` - -## How It Works - -1. The server registers a `get-time` tool with metadata linking it to a UI HTML resource (`ui://get-time/mcp-app.html`). -2. When the tool is invoked, the Host renders the UI from the resource. -3. The UI uses the MCP App SDK API to communicate with the host and call server tools. - -## Build System - -This example bundles into a single HTML file using Vite with `vite-plugin-singlefile` โ€” see [`vite.config.ts`](vite.config.ts). This allows all UI content to be served as a single MCP resource. Alternatively, MCP apps can load external resources by defining [`_meta.ui.csp.resourceDomains`](https://apps.extensions.modelcontextprotocol.io/api/interfaces/app.McpUiResourceCsp.html#resourcedomains) in the UI resource metadata. diff --git a/examples/basic-server-vue/env.d.ts b/examples/basic-server-vue/env.d.ts deleted file mode 100644 index cd39087..0000000 --- a/examples/basic-server-vue/env.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/// - -declare module "*.vue" { - import type { DefineComponent } from "vue"; - const component: DefineComponent; - export default component; -} diff --git a/examples/basic-server-vue/main.ts b/examples/basic-server-vue/main.ts deleted file mode 100644 index d774269..0000000 --- a/examples/basic-server-vue/main.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Entry point for running the MCP server. - * Run with: npx mcp-server-basic-vue - * Or: node dist/index.js [--stdio] - */ - -import { createMcpExpressApp } from "@modelcontextprotocol/express"; -import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node"; -import type { McpServer } from "@modelcontextprotocol/server"; -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; -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. - * - * @param createServer - Factory function that creates a new McpServer instance per request. - */ -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 NodeStreamableHTTPServerTransport({ - 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. - * - * @param createServer - Factory function that creates a new McpServer instance. - */ -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/examples/basic-server-vue/mcp-app.html b/examples/basic-server-vue/mcp-app.html deleted file mode 100644 index 6bac3c9..0000000 --- a/examples/basic-server-vue/mcp-app.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - Get Time App - - - -
- - - diff --git a/examples/basic-server-vue/package.json b/examples/basic-server-vue/package.json deleted file mode 100644 index 858dd89..0000000 --- a/examples/basic-server-vue/package.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "name": "@modelcontextprotocol/server-basic-vue", - "version": "2.0.0", - "type": "module", - "description": "Basic MCP App Server example using Vue", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", - "directory": "examples/basic-server-vue" - }, - "license": "MIT", - "main": "dist/server.js", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"", - "watch": "cross-env INPUT=mcp-app.html vite build --watch", - "serve": "bun --watch main.ts", - "serve:stdio": "bun main.ts --stdio", - "start": "cross-env NODE_ENV=development npm run build && npm run serve", - "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio", - "dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"", - "prepublishOnly": "npm run build" - }, - "dependencies": { - "@modelcontextprotocol/client": "2.0.0", - "@modelcontextprotocol/express": "2.0.0", - "@modelcontextprotocol/ext-apps": "^2.0.0", - "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", - "cors": "^2.8.5", - "express": "^5.1.0", - "vue": "^3.5.0", - "zod": "^4.2.0" - }, - "devDependencies": { - "@types/cors": "^2.8.19", - "@types/express": "^5.0.0", - "@types/node": "22.10.0", - "@vitejs/plugin-vue": "^5.0.0", - "concurrently": "^9.2.1", - "cross-env": "^10.1.0", - "typescript": "^5.9.3", - "vite": "^6.0.0", - "vite-plugin-singlefile": "^2.3.0" - }, - "types": "dist/server.d.ts", - "exports": { - ".": { - "types": "./dist/server.d.ts", - "default": "./dist/server.js" - } - }, - "bin": { - "mcp-server-basic-vue": "dist/index.js" - } -} diff --git a/examples/basic-server-vue/server.ts b/examples/basic-server-vue/server.ts deleted file mode 100644 index 9adb911..0000000 --- a/examples/basic-server-vue/server.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - McpServer, - type CallToolResult, - type ReadResourceResult, -} from "@modelcontextprotocol/server"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; -import { z } from "zod"; -// 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: "Basic MCP App Server (Vue)", - 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: z.object({}), - _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/examples/basic-server-vue/src/App.vue b/examples/basic-server-vue/src/App.vue deleted file mode 100644 index a45c262..0000000 --- a/examples/basic-server-vue/src/App.vue +++ /dev/null @@ -1,221 +0,0 @@ - - - - - diff --git a/examples/basic-server-vue/src/global.css b/examples/basic-server-vue/src/global.css deleted file mode 100644 index 801291f..0000000 --- a/examples/basic-server-vue/src/global.css +++ /dev/null @@ -1,92 +0,0 @@ -:root { - color-scheme: light dark; - - /* - * Fallbacks for host style variables used by this app. - * The host may provide these (and many more) via the host context. - */ - --color-text-primary: light-dark(#1f2937, #f3f4f6); - --color-text-inverse: light-dark(#f3f4f6, #1f2937); - --color-text-info: light-dark(#1d4ed8, #60a5fa); - --color-background-primary: light-dark(#ffffff, #1a1a1a); - --color-background-inverse: light-dark(#1a1a1a, #ffffff); - --color-background-info: light-dark(#eff6ff, #1e3a5f); - --color-ring-primary: light-dark(#3b82f6, #60a5fa); - --border-radius-md: 6px; - --border-width-regular: 1px; - --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; - --font-weight-normal: 400; - --font-weight-bold: 700; - --font-text-md-size: 1rem; - --font-text-md-line-height: 1.5; - --font-heading-3xl-size: 2.25rem; - --font-heading-3xl-line-height: 1.1; - --font-heading-2xl-size: 1.875rem; - --font-heading-2xl-line-height: 1.2; - --font-heading-xl-size: 1.5rem; - --font-heading-xl-line-height: 1.25; - --font-heading-lg-size: 1.25rem; - --font-heading-lg-line-height: 1.3; - --font-heading-md-size: 1rem; - --font-heading-md-line-height: 1.4; - --font-heading-sm-size: 0.875rem; - --font-heading-sm-line-height: 1.4; - - /* Spacing derived from host typography */ - --spacing-unit: var(--font-text-md-size); - --spacing-xs: calc(var(--spacing-unit) * 0.25); - --spacing-sm: calc(var(--spacing-unit) * 0.5); - --spacing-md: var(--spacing-unit); - --spacing-lg: calc(var(--spacing-unit) * 1.5); - - /* App accent color (customize for your brand) */ - --color-accent: #2563eb; - --color-text-on-accent: #ffffff; -} - -* { - box-sizing: border-box; -} - -html, body { - font-family: var(--font-sans); - font-size: var(--font-text-md-size); - font-weight: var(--font-weight-normal); - line-height: var(--font-text-md-line-height); - color: var(--color-text-primary); -} - -h1 { - font-size: var(--font-heading-3xl-size); - line-height: var(--font-heading-3xl-line-height); -} -h2 { - font-size: var(--font-heading-2xl-size); - line-height: var(--font-heading-2xl-line-height); -} -h3 { - font-size: var(--font-heading-xl-size); - line-height: var(--font-heading-xl-line-height); -} -h4 { - font-size: var(--font-heading-lg-size); - line-height: var(--font-heading-lg-line-height); -} -h5 { - font-size: var(--font-heading-md-size); - line-height: var(--font-heading-md-line-height); -} -h6 { - font-size: var(--font-heading-sm-size); - line-height: var(--font-heading-sm-line-height); -} - -code, pre, kbd { - font-family: var(--font-mono); - font-size: 1em; -} - -b, strong { - font-weight: var(--font-weight-bold); -} diff --git a/examples/basic-server-vue/src/mcp-app.ts b/examples/basic-server-vue/src/mcp-app.ts deleted file mode 100644 index 92a6bd5..0000000 --- a/examples/basic-server-vue/src/mcp-app.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createApp } from "vue"; -import App from "./App.vue"; -import "./global.css"; - -createApp(App).mount("#app"); diff --git a/examples/basic-server-vue/tsconfig.json b/examples/basic-server-vue/tsconfig.json deleted file mode 100644 index 4fd3cd7..0000000 --- a/examples/basic-server-vue/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "types": ["node", "vite/client"], - "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", "*.d.ts"] -} diff --git a/examples/basic-server-vue/tsconfig.server.json b/examples/basic-server-vue/tsconfig.server.json deleted file mode 100644 index 27eb28f..0000000 --- a/examples/basic-server-vue/tsconfig.server.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "types": ["node"], - "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/examples/basic-server-vue/vite.config.ts b/examples/basic-server-vue/vite.config.ts deleted file mode 100644 index 8baddce..0000000 --- a/examples/basic-server-vue/vite.config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { defineConfig } from "vite"; -import vue from "@vitejs/plugin-vue"; -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: [vue(), viteSingleFile()], - build: { - sourcemap: isDevelopment ? "inline" : undefined, - cssMinify: !isDevelopment, - minify: !isDevelopment, - - rollupOptions: { - input: INPUT, - }, - outDir: "dist", - emptyOutDir: false, - }, -}); diff --git a/examples/budget-allocator-server/.gitignore b/examples/budget-allocator-server/.gitignore deleted file mode 100644 index b947077..0000000 --- a/examples/budget-allocator-server/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -dist/ diff --git a/examples/budget-allocator-server/README.md b/examples/budget-allocator-server/README.md index d4da76f..6597fa9 100644 --- a/examples/budget-allocator-server/README.md +++ b/examples/budget-allocator-server/README.md @@ -4,51 +4,12 @@ An interactive budget allocation tool demonstrating real-time data visualization - - - + + +
InitialOver budgetTech-heavyInitialOver budgetTech-heavy
-## MCP Client Configuration - -Add to your MCP client configuration (stdio transport): - -```json -{ - "mcpServers": { - "budget-allocator": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-budget-allocator", - "--stdio" - ] - } - } -} -``` - -### Local Development - -To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path): - -```json -{ - "mcpServers": { - "budget-allocator": { - "command": "bash", - "args": [ - "-c", - "cd ~/code/ext-apps/examples/budget-allocator-server && npm run build >&2 && node dist/index.js --stdio" - ] - } - } -} -``` - ## Features - **Interactive Sliders**: Adjust budget allocation across 5 categories (Marketing, Engineering, Operations, Sales, R&D) @@ -69,11 +30,11 @@ To test local modifications, use this configuration (replace `~/code/ext-apps` w 2. Build and start the server: ```bash - npm run start:http # for Streamable HTTP transport - # OR - npm run start:stdio # for stdio transport + npm start ``` + The server will listen on `http://localhost:3001/mcp`. + 3. View using the [`basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example or another MCP Apps-compatible host. ## Architecture @@ -86,7 +47,7 @@ Exposes a single `get-budget-data` tool that returns: - Historical data (~120 data points) - 24 months of allocation history per category - Industry benchmarks (~60 data points) - Aggregated percentile data by company stage -The tool is linked to a UI resource via `_meta.ui.resourceUri`. +The tool is linked to a UI resource via `_meta[RESOURCE_URI_META_KEY]`. ### App (`src/mcp-app.ts`) diff --git a/examples/budget-allocator-server/grid-cell.png b/examples/budget-allocator-server/grid-cell.png deleted file mode 100644 index dee5a31..0000000 Binary files a/examples/budget-allocator-server/grid-cell.png and /dev/null differ diff --git a/examples/budget-allocator-server/main.ts b/examples/budget-allocator-server/main.ts deleted file mode 100644 index 02c03cb..0000000 --- a/examples/budget-allocator-server/main.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Entry point for running the MCP server. - * Run with: npx mcp-budget-allocator-server - * Or: node dist/index.js [--stdio] - */ - -import { createMcpExpressApp } from "@modelcontextprotocol/express"; -import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node"; -import type { McpServer } from "@modelcontextprotocol/server"; -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; -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. - * - * @param createServer - Factory function that creates a new McpServer instance per request. - */ -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 NodeStreamableHTTPServerTransport({ - 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. - * - * @param createServer - Factory function that creates a new McpServer instance. - */ -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/examples/budget-allocator-server/mcp-app.html b/examples/budget-allocator-server/mcp-app.html index d8724f4..e57c137 100644 --- a/examples/budget-allocator-server/mcp-app.html +++ b/examples/budget-allocator-server/mcp-app.html @@ -3,7 +3,6 @@ - Budget Allocator diff --git a/examples/budget-allocator-server/package.json b/examples/budget-allocator-server/package.json index 6e41622..5493b52 100644 --- a/examples/budget-allocator-server/package.json +++ b/examples/budget-allocator-server/package.json @@ -1,58 +1,30 @@ { - "name": "@modelcontextprotocol/server-budget-allocator", - "version": "2.0.0", + "name": "budget-allocator-server", + "version": "1.0.0", + "private": true, "type": "module", - "description": "Budget allocator MCP App Server with interactive visualization", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", - "directory": "examples/budget-allocator-server" - }, - "license": "MIT", - "main": "dist/server.js", - "files": [ - "dist" - ], "scripts": { - "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"", - "watch": "cross-env INPUT=mcp-app.html vite build --watch", - "serve:http": "bun --watch main.ts", - "serve:stdio": "bun main.ts --stdio", - "start": "npm run start:http", - "start:http": "cross-env NODE_ENV=development npm run build && npm run serve:http", - "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio", - "dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve:http\"", - "prepublishOnly": "npm run build", - "serve": "bun --watch main.ts" + "build": "INPUT=mcp-app.html vite build", + "watch": "INPUT=mcp-app.html vite build --watch", + "serve": "bun server.ts", + "start": "NODE_ENV=development npm run build && npm run serve", + "dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve'" }, "dependencies": { - "@modelcontextprotocol/express": "2.0.0", - "@modelcontextprotocol/ext-apps": "^2.0.0", - "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/ext-apps": "../..", + "@modelcontextprotocol/sdk": "^1.22.0", "chart.js": "^4.4.0", - "cors": "^2.8.5", - "express": "^5.1.0", - "zod": "^4.2.0" + "zod": "^3.25.0" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.0", - "@types/node": "22.10.0", + "@types/node": "^22.0.0", "concurrently": "^9.2.1", - "cross-env": "^10.1.0", + "cors": "^2.8.5", + "express": "^5.1.0", "typescript": "^5.9.3", "vite": "^6.0.0", "vite-plugin-singlefile": "^2.3.0" - }, - "types": "dist/server.d.ts", - "exports": { - ".": { - "types": "./dist/server.d.ts", - "default": "./dist/server.js" - } - }, - "bin": { - "mcp-budget-allocator-server": "dist/index.js" } } diff --git a/examples/budget-allocator-server/screenshot.png b/examples/budget-allocator-server/screenshot.png deleted file mode 100644 index 1bd9783..0000000 Binary files a/examples/budget-allocator-server/screenshot.png and /dev/null differ diff --git a/examples/budget-allocator-server/server.ts b/examples/budget-allocator-server/server.ts old mode 100755 new mode 100644 index 01f5dbf..c05c873 --- a/examples/budget-allocator-server/server.ts +++ b/examples/budget-allocator-server/server.ts @@ -4,23 +4,22 @@ * Provides budget configuration, 24 months of historical allocation data, * and industry benchmarks by company stage. */ -import { - McpServer, - type CallToolResult, - type ReadResourceResult, -} from "@modelcontextprotocol/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import type { + CallToolResult, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; +import cors from "cors"; +import express, { type Request, type Response } from "express"; import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; -import { - RESOURCE_MIME_TYPE, - registerAppResource, - registerAppTool, -} from "@modelcontextprotocol/ext-apps/server"; -// 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; +import { RESOURCE_URI_META_KEY } from "../../dist/src/app"; + +const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3001; +const DIST_DIR = path.join(import.meta.dirname, "dist"); // --------------------------------------------------------------------------- // Schemas - types are derived from these using z.infer @@ -223,115 +222,135 @@ function generateHistory( return months; } -// --------------------------------------------------------------------------- -// Response Formatting -// --------------------------------------------------------------------------- - -function formatBudgetSummary(data: BudgetDataResponse): string { - const lines: string[] = [ - "Budget Allocator Configuration", - "==============================", - "", - `Default Budget: ${data.config.currencySymbol}${data.config.defaultBudget.toLocaleString()}`, - `Available Presets: ${data.config.presetBudgets.map((b) => `${data.config.currencySymbol}${b.toLocaleString()}`).join(", ")}`, - "", - "Categories:", - ...data.config.categories.map( - (c) => ` - ${c.name}: ${c.defaultPercent}% default`, - ), - "", - `Historical Data: ${data.analytics.history.length} months`, - `Benchmark Stages: ${data.analytics.stages.join(", ")}`, - `Default Stage: ${data.analytics.defaultStage}`, - ]; - return lines.join("\n"); -} - // --------------------------------------------------------------------------- // MCP Server Setup // --------------------------------------------------------------------------- +const server = new McpServer({ + name: "Budget Allocator Server", + version: "1.0.0", +}); + const resourceUri = "ui://budget-allocator/mcp-app.html"; -/** - * Creates a new MCP server instance with tools and resources registered. - * Each HTTP session needs its own server instance because McpServer only supports one transport. - */ -export function createServer(): McpServer { - const server = new McpServer({ - name: "Budget Allocator Server", - version: "1.0.0", - }); +server.registerTool( + "get-budget-data", + { + title: "Get Budget Data", + description: + "Returns budget configuration with 24 months of historical allocations and industry benchmarks by company stage", + inputSchema: {}, + _meta: { [RESOURCE_URI_META_KEY]: resourceUri }, + }, + async (): Promise => { + const response: BudgetDataResponse = { + config: { + categories: CATEGORIES.map(({ id, name, color, defaultPercent }) => ({ + id, + name, + color, + defaultPercent, + })), + presetBudgets: [50000, 100000, 250000, 500000], + defaultBudget: 100000, + currency: "USD", + currencySymbol: "$", + }, + analytics: { + history: generateHistory(CATEGORIES), + benchmarks: BENCHMARKS, + stages: ["Seed", "Series A", "Series B", "Growth"], + defaultStage: "Series A", + }, + }; - registerAppTool( - server, - "get-budget-data", - { - title: "Get Budget Data", - description: - "Returns budget configuration with 24 months of historical allocations and industry benchmarks by company stage. The widget is interactive and exposes tools for reading/modifying allocations, adjusting budgets, and comparing against industry benchmarks.", - inputSchema: z.object({}), - outputSchema: BudgetDataResponseSchema, - _meta: { ui: { resourceUri } }, - }, - async (): Promise => { - const response: BudgetDataResponse = { - config: { - categories: CATEGORIES.map(({ id, name, color, defaultPercent }) => ({ - id, - name, - color, - defaultPercent, - })), - presetBudgets: [50000, 100000, 250000, 500000], - defaultBudget: 100000, - currency: "USD", - currencySymbol: "$", + return { + content: [ + { + type: "text", + text: JSON.stringify(response), }, - analytics: { - history: generateHistory(CATEGORIES), - benchmarks: BENCHMARKS, - stages: ["Seed", "Series A", "Series B", "Growth"], - defaultStage: "Series A", - }, - }; + ], + }; + }, +); - return { - content: [ - { - type: "text", - text: formatBudgetSummary(response), - }, - ], - structuredContent: response, - }; - }, - ); - - registerAppResource( - server, - resourceUri, - resourceUri, - { - mimeType: RESOURCE_MIME_TYPE, - description: "Interactive Budget Allocator UI", - }, - 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; -} +server.registerResource( + resourceUri, + resourceUri, + { description: "Interactive Budget Allocator UI" }, + async (): Promise => { + const html = await fs.readFile( + path.join(DIST_DIR, "mcp-app.html"), + "utf-8", + ); + return { + contents: [ + { uri: resourceUri, mimeType: "text/html;profile=mcp-app", text: html }, + ], + }; + }, +); // --------------------------------------------------------------------------- // Server Startup // --------------------------------------------------------------------------- + +async function main() { + if (process.argv.includes("--stdio")) { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("Budget Allocator Server running in stdio mode"); + } else { + const app = express(); + app.use(cors()); + app.use(express.json()); + + app.post("/mcp", async (req: Request, res: Response) => { + try { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + res.on("close", () => { + transport.close(); + }); + + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("Error handling MCP request:", 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("Error starting server:", err); + process.exit(1); + } + console.log( + `Budget Allocator Server listening on http://localhost:${PORT}/mcp`, + ); + }); + + function shutdown() { + console.log("\nShutting down..."); + httpServer.close(() => { + console.log("Server closed"); + process.exit(0); + }); + } + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + } +} + +main().catch(console.error); diff --git a/examples/budget-allocator-server/src/mcp-app.ts b/examples/budget-allocator-server/src/mcp-app.ts index a9bae12..723dc06 100644 --- a/examples/budget-allocator-server/src/mcp-app.ts +++ b/examples/budget-allocator-server/src/mcp-app.ts @@ -1,9 +1,8 @@ /** * Budget Allocator App - Interactive budget allocation with real-time visualization */ -import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps"; import { Chart, registerables } from "chart.js"; -import { z } from "zod"; import "./global.css"; import "./mcp-app.css"; @@ -88,7 +87,6 @@ const state: AppState = { // DOM References // --------------------------------------------------------------------------- -const appContainer = document.querySelector(".app-container") as HTMLElement; const budgetSelector = document.getElementById( "budget-selector", ) as HTMLSelectElement; @@ -608,7 +606,13 @@ const app = new App({ name: "Budget Allocator", version: "1.0.0" }); app.ontoolresult = (result) => { log.info("Received tool result:", result); - const data = result.structuredContent as unknown as BudgetDataResponse; + const text = result + .content!.filter( + (c): c is { type: "text"; text: string } => c.type === "text", + ) + .map((c) => c.text) + .join(""); + const data = JSON.parse(text) as BudgetDataResponse; if (data?.config && data?.analytics) { initializeUI(data.config, data.analytics); } @@ -616,306 +620,6 @@ app.ontoolresult = (result) => { app.onerror = log.error; -function handleHostContextChanged(ctx: McpUiHostContext) { - if (ctx.safeAreaInsets) { - appContainer.style.paddingTop = `${ctx.safeAreaInsets.top}px`; - appContainer.style.paddingRight = `${ctx.safeAreaInsets.right}px`; - appContainer.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`; - appContainer.style.paddingLeft = `${ctx.safeAreaInsets.left}px`; - } -} - -app.onhostcontextchanged = handleHostContextChanged; - -// Register tools for model interaction -app.registerTool( - "get-allocations", - { - title: "Get Budget Allocations", - description: - "Get the current budget allocations including total budget, percentages, and amounts per category", - }, - async () => { - if (!state.config) { - return { - content: [ - { type: "text" as const, text: "Error: Configuration not loaded" }, - ], - isError: true, - }; - } - - const allocations: Record = {}; - for (const category of state.config.categories) { - const percent = state.allocations.get(category.id) ?? 0; - allocations[category.id] = { - percent, - amount: (percent / 100) * state.totalBudget, - }; - } - - const result = { - totalBudget: state.totalBudget, - currency: state.config.currency, - currencySymbol: state.config.currencySymbol, - selectedStage: state.selectedStage, - allocations, - categories: state.config.categories.map((c) => ({ - id: c.id, - name: c.name, - color: c.color, - })), - }; - - return { - content: [ - { type: "text" as const, text: JSON.stringify(result, null, 2) }, - ], - structuredContent: result, - }; - }, -); - -app.registerTool( - "set-allocation", - { - title: "Set Category Allocation", - description: "Set the allocation percentage for a specific budget category", - inputSchema: z.object({ - categoryId: z - .string() - .describe( - "Category ID (e.g., 'marketing', 'engineering', 'operations', 'sales', 'rd')", - ), - percent: z - .number() - .min(0) - .max(100) - .describe("Allocation percentage (0-100)"), - }), - }, - async (args) => { - if (!state.config) { - return { - content: [ - { type: "text" as const, text: "Error: Configuration not loaded" }, - ], - isError: true, - }; - } - - const category = state.config.categories.find( - (c) => c.id === args.categoryId, - ); - if (!category) { - return { - content: [ - { - type: "text" as const, - text: `Error: Category "${args.categoryId}" not found. Available: ${state.config.categories.map((c) => c.id).join(", ")}`, - }, - ], - isError: true, - }; - } - - handleSliderChange(args.categoryId, args.percent); - - // Also update the slider UI - const slider = document.querySelector( - `.slider-row[data-category-id="${args.categoryId}"] .slider`, - ) as HTMLInputElement | null; - if (slider) { - slider.value = String(args.percent); - } - - const amount = (args.percent / 100) * state.totalBudget; - return { - content: [ - { - type: "text" as const, - text: `Set ${category.name} allocation to ${args.percent.toFixed(1)}% (${state.config.currencySymbol}${amount.toLocaleString()})`, - }, - ], - }; - }, -); - -app.registerTool( - "set-total-budget", - { - title: "Set Total Budget", - description: "Set the total budget amount", - inputSchema: z.object({ - amount: z.number().positive().describe("Total budget amount"), - }), - }, - async (args) => { - if (!state.config) { - return { - content: [ - { type: "text" as const, text: "Error: Configuration not loaded" }, - ], - isError: true, - }; - } - - state.totalBudget = args.amount; - - // Update the budget selector if this amount is a preset - const budgetSelector = document.getElementById( - "budget-selector", - ) as HTMLSelectElement | null; - if (budgetSelector) { - const option = Array.from(budgetSelector.options).find( - (opt) => parseInt(opt.value) === args.amount, - ); - if (option) { - budgetSelector.value = String(args.amount); - } - } - - updateAllSliderAmounts(); - updateStatusBar(); - updateComparisonSummary(); - - return { - content: [ - { - type: "text" as const, - text: `Total budget set to ${state.config.currencySymbol}${args.amount.toLocaleString()}`, - }, - ], - }; - }, -); - -app.registerTool( - "set-company-stage", - { - title: "Set Company Stage", - description: - "Set the company stage for benchmark comparison (e.g., 'Seed', 'Series A', 'Series B', 'Growth')", - inputSchema: z.object({ - stage: z.string().describe("Company stage ID"), - }), - }, - async (args) => { - if (!state.analytics) { - return { - content: [ - { type: "text" as const, text: "Error: Analytics not loaded" }, - ], - isError: true, - }; - } - - if (!state.analytics.stages.includes(args.stage)) { - return { - content: [ - { - type: "text" as const, - text: `Error: Stage "${args.stage}" not found. Available: ${state.analytics.stages.join(", ")}`, - }, - ], - isError: true, - }; - } - - state.selectedStage = args.stage; - - // Update the stage selector UI - const stageSelector = document.getElementById( - "stage-selector", - ) as HTMLSelectElement | null; - if (stageSelector) { - stageSelector.value = args.stage; - } - - // Update all badges and summary - if (state.config) { - for (const category of state.config.categories) { - updatePercentileBadge(category.id); - } - updateComparisonSummary(); - } - - return { - content: [ - { - type: "text" as const, - text: `Company stage set to "${args.stage}"`, - }, - ], - }; - }, -); - -app.registerTool( - "get-benchmark-comparison", - { - title: "Get Benchmark Comparison", - description: - "Compare current allocations against industry benchmarks for the selected stage", - }, - async () => { - if (!state.config || !state.analytics) { - return { - content: [{ type: "text" as const, text: "Error: Data not loaded" }], - isError: true, - }; - } - - const benchmark = state.analytics.benchmarks.find( - (b) => b.stage === state.selectedStage, - ); - if (!benchmark) { - return { - content: [ - { - type: "text" as const, - text: `Error: No benchmark data for stage "${state.selectedStage}"`, - }, - ], - isError: true, - }; - } - - const comparison: Record< - string, - { current: number; p25: number; p50: number; p75: number; status: string } - > = {}; - - for (const category of state.config.categories) { - const current = state.allocations.get(category.id) ?? 0; - const benchmarkData = benchmark.categoryBenchmarks[category.id]; - let status = "within range"; - if (current < benchmarkData.p25) status = "below p25"; - else if (current > benchmarkData.p75) status = "above p75"; - - comparison[category.id] = { - current, - p25: benchmarkData.p25, - p50: benchmarkData.p50, - p75: benchmarkData.p75, - status, - }; - } - - const result = { - stage: state.selectedStage, - comparison, - }; - - return { - content: [ - { type: "text" as const, text: JSON.stringify(result, null, 2) }, - ], - structuredContent: result, - }; - }, -); - // Handle theme changes window .matchMedia("(prefers-color-scheme: dark)") @@ -927,9 +631,4 @@ window }); // Connect to host -app.connect().then(() => { - const ctx = app.getHostContext(); - if (ctx) { - handleHostContextChanged(ctx); - } -}); +app.connect(new PostMessageTransport(window.parent)); diff --git a/examples/budget-allocator-server/tsconfig.json b/examples/budget-allocator-server/tsconfig.json index b028ff5..535267b 100644 --- a/examples/budget-allocator-server/tsconfig.json +++ b/examples/budget-allocator-server/tsconfig.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "types": ["node", "vite/client"], "target": "ESNext", "lib": ["ESNext", "DOM", "DOM.Iterable"], "module": "ESNext", diff --git a/examples/budget-allocator-server/tsconfig.server.json b/examples/budget-allocator-server/tsconfig.server.json deleted file mode 100644 index 27eb28f..0000000 --- a/examples/budget-allocator-server/tsconfig.server.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "types": ["node"], - "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/examples/cohort-heatmap-server/.gitignore b/examples/cohort-heatmap-server/.gitignore deleted file mode 100644 index b947077..0000000 --- a/examples/cohort-heatmap-server/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -dist/ diff --git a/examples/cohort-heatmap-server/README.md b/examples/cohort-heatmap-server/README.md index c011933..7b0ec18 100644 --- a/examples/cohort-heatmap-server/README.md +++ b/examples/cohort-heatmap-server/README.md @@ -4,51 +4,12 @@ A demo MCP App that displays cohort retention data as an interactive heatmap, sh - - - + + +
Initial viewHover stateLow retention hoverInitial viewHover stateLow retention hover
-## MCP Client Configuration - -Add to your MCP client configuration (stdio transport): - -```json -{ - "mcpServers": { - "cohort-heatmap": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-cohort-heatmap", - "--stdio" - ] - } - } -} -``` - -### Local Development - -To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path): - -```json -{ - "mcpServers": { - "cohort-heatmap": { - "command": "bash", - "args": [ - "-c", - "cd ~/code/ext-apps/examples/cohort-heatmap-server && npm run build >&2 && node dist/index.js --stdio" - ] - } - } -} -``` - ## Features - **Cohort Retention Heatmap**: Color-coded grid showing retention percentages across cohorts and time periods @@ -69,11 +30,11 @@ To test local modifications, use this configuration (replace `~/code/ext-apps` w 2. Build and start the server: ```bash - npm run start:http # for Streamable HTTP transport - # OR - npm run start:stdio # for stdio transport + npm start ``` + The server will listen on `http://localhost:3001/mcp`. + 3. View using the [`basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example or another MCP Apps-compatible host. ## Architecture diff --git a/examples/cohort-heatmap-server/grid-cell.png b/examples/cohort-heatmap-server/grid-cell.png deleted file mode 100644 index 4349361..0000000 Binary files a/examples/cohort-heatmap-server/grid-cell.png and /dev/null differ diff --git a/examples/cohort-heatmap-server/main.ts b/examples/cohort-heatmap-server/main.ts deleted file mode 100644 index 8b33603..0000000 --- a/examples/cohort-heatmap-server/main.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Entry point for running the MCP server. - * Run with: npx mcp-cohort-heatmap-server - * Or: node dist/index.js [--stdio] - */ - -import { createMcpExpressApp } from "@modelcontextprotocol/express"; -import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node"; -import type { McpServer } from "@modelcontextprotocol/server"; -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; -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. - * - * @param createServer - Factory function that creates a new McpServer instance per request. - */ -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 NodeStreamableHTTPServerTransport({ - 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. - * - * @param createServer - Factory function that creates a new McpServer instance. - */ -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/examples/cohort-heatmap-server/mcp-app.html b/examples/cohort-heatmap-server/mcp-app.html index c9202a1..2b1a47d 100644 --- a/examples/cohort-heatmap-server/mcp-app.html +++ b/examples/cohort-heatmap-server/mcp-app.html @@ -3,7 +3,6 @@ - Cohort Retention Heatmap diff --git a/examples/cohort-heatmap-server/package.json b/examples/cohort-heatmap-server/package.json index 7e2d2e2..6769b1c 100644 --- a/examples/cohort-heatmap-server/package.json +++ b/examples/cohort-heatmap-server/package.json @@ -1,62 +1,34 @@ { - "name": "@modelcontextprotocol/server-cohort-heatmap", - "version": "2.0.0", + "name": "cohort-heatmap-server", + "version": "1.0.0", + "private": true, "type": "module", - "description": "Cohort heatmap MCP App Server for retention analysis", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", - "directory": "examples/cohort-heatmap-server" - }, - "license": "MIT", - "main": "dist/server.js", - "files": [ - "dist" - ], "scripts": { - "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"", - "watch": "cross-env INPUT=mcp-app.html vite build --watch", - "serve:http": "bun --watch main.ts", - "serve:stdio": "bun main.ts --stdio", - "start": "npm run start:http", - "start:http": "cross-env NODE_ENV=development npm run build && npm run serve:http", - "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio", - "dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve:http\"", - "prepublishOnly": "npm run build", - "serve": "bun --watch main.ts" + "build": "INPUT=mcp-app.html vite build", + "watch": "INPUT=mcp-app.html vite build --watch", + "serve": "bun server.ts", + "start": "NODE_ENV=development npm run build && npm run serve", + "dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve'" }, "dependencies": { - "@modelcontextprotocol/express": "2.0.0", - "@modelcontextprotocol/ext-apps": "^2.0.0", - "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", - "cors": "^2.8.5", - "express": "^5.1.0", + "@modelcontextprotocol/ext-apps": "../..", + "@modelcontextprotocol/sdk": "^1.22.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "zod": "^4.2.0" + "zod": "^3.25.0" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.0", - "@types/node": "22.10.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", + "cors": "^2.8.5", + "express": "^5.1.0", "typescript": "^5.9.3", "vite": "^6.0.0", "vite-plugin-singlefile": "^2.3.0" - }, - "types": "dist/server.d.ts", - "exports": { - ".": { - "types": "./dist/server.d.ts", - "default": "./dist/server.js" - } - }, - "bin": { - "mcp-cohort-heatmap-server": "dist/index.js" } } diff --git a/examples/cohort-heatmap-server/screenshot.png b/examples/cohort-heatmap-server/screenshot.png deleted file mode 100644 index e469da8..0000000 Binary files a/examples/cohort-heatmap-server/screenshot.png and /dev/null differ diff --git a/examples/cohort-heatmap-server/server.ts b/examples/cohort-heatmap-server/server.ts index 129e2dc..76fa86e 100644 --- a/examples/cohort-heatmap-server/server.ts +++ b/examples/cohort-heatmap-server/server.ts @@ -1,19 +1,16 @@ -import { - McpServer, - type ReadResourceResult, -} from "@modelcontextprotocol/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; +import cors from "cors"; +import express, { type Request, type Response } from "express"; import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; -import { - RESOURCE_MIME_TYPE, - registerAppResource, - registerAppTool, -} from "@modelcontextprotocol/ext-apps/server"; -// 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; +import { RESOURCE_URI_META_KEY } from "../../dist/src/app"; + +const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3001; +const DIST_DIR = path.join(import.meta.dirname, "dist"); // Schemas - types are derived from these using z.infer const GetCohortDataInputSchema = z.object({ @@ -153,36 +150,23 @@ function generateCohortData( }; } -function formatCohortSummary(data: CohortData): string { - const avgRetention = data.cohorts - .flatMap((c) => c.cells) - .filter((cell) => cell.periodIndex > 0) - .reduce((sum, cell, _, arr) => sum + cell.retention / arr.length, 0); +const server = new McpServer({ + name: "Cohort Heatmap Server", + version: "1.0.0", +}); - return `Cohort Analysis: ${data.cohorts.length} cohorts, ${data.periods.length} periods -Average retention: ${(avgRetention * 100).toFixed(1)}% -Metric: ${data.metric}, Period: ${data.periodType}`; -} - -export function createServer(): McpServer { - const server = new McpServer({ - name: "Cohort Heatmap Server", - version: "1.0.0", - }); - - // Register tool and resource +// Register tool and resource +{ const resourceUri = "ui://get-cohort-data/mcp-app.html"; - registerAppTool( - server, + server.registerTool( "get-cohort-data", { title: "Get Cohort Retention Data", description: "Returns cohort retention heatmap data showing customer retention over time by signup month", - inputSchema: GetCohortDataInputSchema, - outputSchema: CohortDataSchema, - _meta: { ui: { resourceUri } }, + inputSchema: GetCohortDataInputSchema.shape, + _meta: { [RESOURCE_URI_META_KEY]: resourceUri }, }, async ({ metric, periodType, cohortCount, maxPeriods }) => { const data = generateCohortData( @@ -193,17 +177,15 @@ export function createServer(): McpServer { ); return { - content: [{ type: "text", text: formatCohortSummary(data) }], - structuredContent: data, + content: [{ type: "text", text: JSON.stringify(data) }], }; }, ); - registerAppResource( - server, + server.registerResource( resourceUri, resourceUri, - { mimeType: RESOURCE_MIME_TYPE }, + {}, async (): Promise => { const html = await fs.readFile( path.join(DIST_DIR, "mcp-app.html"), @@ -214,13 +196,71 @@ export function createServer(): McpServer { contents: [ { uri: resourceUri, - mimeType: RESOURCE_MIME_TYPE, + mimeType: "text/html;profile=mcp-app", text: html, }, ], }; }, ); - - return server; } + +async function main() { + if (process.argv.includes("--stdio")) { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("Cohort Heatmap Server running in stdio mode"); + } else { + const app = express(); + app.use(cors()); + app.use(express.json()); + + app.post("/mcp", async (req: Request, res: Response) => { + try { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + res.on("close", () => { + transport.close(); + }); + + await server.connect(transport); + + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("Error handling MCP request:", 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("Error starting server:", err); + process.exit(1); + } + console.log( + `Cohort Heatmap Server listening on http://localhost:${PORT}/mcp`, + ); + }); + + function shutdown() { + console.log("\nShutting down..."); + httpServer.close(() => { + console.log("Server closed"); + process.exit(0); + }); + } + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + } +} + +main().catch(console.error); diff --git a/examples/cohort-heatmap-server/src/mcp-app.tsx b/examples/cohort-heatmap-server/src/mcp-app.tsx index 919b6d1..42270a9 100644 --- a/examples/cohort-heatmap-server/src/mcp-app.tsx +++ b/examples/cohort-heatmap-server/src/mcp-app.tsx @@ -4,7 +4,7 @@ * Interactive cohort retention analysis heatmap showing customer retention * over time by signup month. Hover for details, click to drill down. */ -import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import type { App } from "@modelcontextprotocol/ext-apps"; import { useApp } from "@modelcontextprotocol/ext-apps/react"; import { StrictMode, useCallback, useEffect, useMemo, useState } from "react"; import { createRoot } from "react-dom/client"; @@ -65,39 +65,19 @@ function formatNumber(n: number): string { // Main App Component function CohortHeatmapApp() { - const [hostContext, setHostContext] = useState< - McpUiHostContext | undefined - >(); const { app, error } = useApp({ appInfo: IMPLEMENTATION, capabilities: {}, - onAppCreated: (app) => { - 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 ; + return ; } // Inner App with state management -function CohortHeatmapInner({ - app, - hostContext, -}: { - app: App; - hostContext?: McpUiHostContext; -}) { +function CohortHeatmapInner({ app }: { app: App }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [selectedMetric, setSelectedMetric] = useState("retention"); @@ -123,7 +103,13 @@ function CohortHeatmapInner({ maxPeriods: 12, }, }); - setData(result.structuredContent as unknown as CohortData); + const text = result + .content!.filter( + (c): c is { type: "text"; text: string } => c.type === "text", + ) + .map((c) => c.text) + .join(""); + setData(JSON.parse(text) as CohortData); } catch (e) { console.error("Failed to fetch cohort data:", e); } finally { @@ -157,15 +143,7 @@ function CohortHeatmapInner({ }, []); return ( -
+
- Account age vs revenue - Tickets, NPS, engagement - Age, engagement, revenue + Account age vs revenue + Tickets, NPS, engagement + Age, engagement, revenue -## MCP Client Configuration - -Add to your MCP client configuration (stdio transport): - -```json -{ - "mcpServers": { - "customer-segmentation": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-customer-segmentation", - "--stdio" - ] - } - } -} -``` - -### Local Development - -To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path): - -```json -{ - "mcpServers": { - "customer-segmentation": { - "command": "bash", - "args": [ - "-c", - "cd ~/code/ext-apps/examples/customer-segmentation-server && npm run build >&2 && node dist/index.js --stdio" - ] - } - } -} -``` - ## Features - **Interactive Scatter Plot**: Bubble chart visualization using Chart.js with configurable X/Y axes @@ -70,11 +31,11 @@ To test local modifications, use this configuration (replace `~/code/ext-apps` w 2. Build and start the server: ```bash - npm run start:http # for Streamable HTTP transport - # OR - npm run start:stdio # for stdio transport + npm start ``` + The server will listen on `http://localhost:3001/mcp`. + 3. View using the [`basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example or another MCP Apps-compatible host. ## Architecture @@ -87,7 +48,7 @@ Exposes a single `get-customer-data` tool that returns: - Segment summary with counts and colors for each group - Optional segment filter parameter -The tool is linked to a UI resource via `_meta.ui.resourceUri`. +The tool is linked to a UI resource via `_meta[RESOURCE_URI_META_KEY]`. ### App (`src/mcp-app.ts`) @@ -102,4 +63,4 @@ The tool is linked to a UI resource via `_meta.ui.resourceUri`. - Generates realistic customer data with Gaussian clustering around segment centers - Each segment has characteristic ranges for revenue, employees, engagement, etc. - Company names generated from word-list combinations (e.g., "Apex Data Corp") -- Data cached in memory for consistency across requests +- Data cached in memory for session consistency diff --git a/examples/customer-segmentation-server/grid-cell.png b/examples/customer-segmentation-server/grid-cell.png deleted file mode 100644 index 992f95b..0000000 Binary files a/examples/customer-segmentation-server/grid-cell.png and /dev/null differ diff --git a/examples/customer-segmentation-server/main.ts b/examples/customer-segmentation-server/main.ts deleted file mode 100644 index c788757..0000000 --- a/examples/customer-segmentation-server/main.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Entry point for running the MCP server. - * Run with: npx mcp-customer-segmentation-server - * Or: node dist/index.js [--stdio] - */ - -import { createMcpExpressApp } from "@modelcontextprotocol/express"; -import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node"; -import type { McpServer } from "@modelcontextprotocol/server"; -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; -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. - * - * @param createServer - Factory function that creates a new McpServer instance per request. - */ -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 NodeStreamableHTTPServerTransport({ - 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. - * - * @param createServer - Factory function that creates a new McpServer instance. - */ -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/examples/customer-segmentation-server/mcp-app.html b/examples/customer-segmentation-server/mcp-app.html index 33a684a..10e584d 100644 --- a/examples/customer-segmentation-server/mcp-app.html +++ b/examples/customer-segmentation-server/mcp-app.html @@ -3,7 +3,6 @@ - Customer Segmentation Explorer diff --git a/examples/customer-segmentation-server/package.json b/examples/customer-segmentation-server/package.json index bac3b89..b23129b 100644 --- a/examples/customer-segmentation-server/package.json +++ b/examples/customer-segmentation-server/package.json @@ -1,58 +1,30 @@ { - "name": "@modelcontextprotocol/server-customer-segmentation", - "version": "2.0.0", + "name": "customer-segmentation-server", + "version": "1.0.0", + "private": true, "type": "module", - "description": "Customer segmentation MCP App Server with filtering", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", - "directory": "examples/customer-segmentation-server" - }, - "license": "MIT", - "main": "dist/server.js", - "files": [ - "dist" - ], "scripts": { - "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"", - "watch": "cross-env INPUT=mcp-app.html vite build --watch", - "serve:http": "bun --watch main.ts", - "serve:stdio": "bun main.ts --stdio", - "start": "npm run start:http", - "start:http": "cross-env NODE_ENV=development npm run build && npm run serve:http", - "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio", - "dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve:http\"", - "prepublishOnly": "npm run build", - "serve": "bun --watch main.ts" + "build": "INPUT=mcp-app.html vite build", + "watch": "INPUT=mcp-app.html vite build --watch", + "serve": "bun server.ts", + "start": "NODE_ENV=development npm run build && npm run serve", + "dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve'" }, "dependencies": { - "@modelcontextprotocol/express": "2.0.0", - "@modelcontextprotocol/ext-apps": "^2.0.0", - "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/ext-apps": "../..", + "@modelcontextprotocol/sdk": "^1.22.0", "chart.js": "^4.4.0", - "cors": "^2.8.5", - "express": "^5.1.0", - "zod": "^4.2.0" + "zod": "^3.25.0" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.0", - "@types/node": "22.10.0", + "@types/node": "^22.0.0", "concurrently": "^9.2.1", - "cross-env": "^10.1.0", + "cors": "^2.8.5", + "express": "^5.1.0", "typescript": "^5.9.3", "vite": "^6.0.0", "vite-plugin-singlefile": "^2.3.0" - }, - "types": "dist/server.d.ts", - "exports": { - ".": { - "types": "./dist/server.d.ts", - "default": "./dist/server.js" - } - }, - "bin": { - "mcp-customer-segmentation-server": "dist/index.js" } } diff --git a/examples/customer-segmentation-server/screenshot.png b/examples/customer-segmentation-server/screenshot.png deleted file mode 100644 index e643013..0000000 Binary files a/examples/customer-segmentation-server/screenshot.png and /dev/null differ diff --git a/examples/customer-segmentation-server/server.ts b/examples/customer-segmentation-server/server.ts index 7cc1c1f..63f8bb9 100644 --- a/examples/customer-segmentation-server/server.ts +++ b/examples/customer-segmentation-server/server.ts @@ -1,26 +1,24 @@ -import { - McpServer, - type CallToolResult, - type ReadResourceResult, -} from "@modelcontextprotocol/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import type { + CallToolResult, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; +import cors from "cors"; +import express, { type Request, type Response } from "express"; import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; -import { - RESOURCE_MIME_TYPE, - registerAppResource, - registerAppTool, -} from "@modelcontextprotocol/ext-apps/server"; +import { RESOURCE_URI_META_KEY } from "../../dist/src/app"; import { generateCustomers, generateSegmentSummaries, -} from "./src/data-generator.js"; -import { SEGMENTS, type Customer, type SegmentSummary } from "./src/types.js"; +} from "./src/data-generator.ts"; +import { SEGMENTS, type Customer, type SegmentSummary } from "./src/types.ts"; -// 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 PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3001; +const DIST_DIR = path.join(import.meta.dirname, "dist"); // Schemas - types are derived from these using z.infer const GetCustomerDataInputSchema = z.object({ @@ -30,29 +28,6 @@ const GetCustomerDataInputSchema = z.object({ .describe("Filter by segment (default: All)"), }); -const CustomerSchema = z.object({ - id: z.string(), - name: z.string(), - segment: z.string(), - annualRevenue: z.number(), - employeeCount: z.number(), - accountAge: z.number(), - engagementScore: z.number(), - supportTickets: z.number(), - nps: z.number(), -}); - -const SegmentSummarySchema = z.object({ - name: z.string(), - count: z.number(), - color: z.string(), -}); - -const GetCustomerDataOutputSchema = z.object({ - customers: z.array(CustomerSchema), - segments: z.array(SegmentSummarySchema), -}); - // Cache generated data for session consistency let cachedCustomers: Customer[] | null = null; let cachedSegments: SegmentSummary[] | null = null; @@ -79,67 +54,112 @@ function getCustomerData(segmentFilter?: string): { }; } -/** - * Creates a new MCP server instance with tools and resources registered. - * Each HTTP session needs its own server instance because McpServer only supports one transport. - */ -export function createServer(): McpServer { - const server = new McpServer({ - name: "Customer Segmentation Server", - version: "1.0.0", - }); +const server = new McpServer({ + name: "Customer Segmentation Server", + version: "1.0.0", +}); - // Register the get-customer-data tool and its associated UI resource - { - const resourceUri = "ui://customer-segmentation/mcp-app.html"; +// Register the get-customer-data tool and its associated UI resource +{ + const resourceUri = "ui://customer-segmentation/mcp-app.html"; - registerAppTool( - server, - "get-customer-data", - { - title: "Get Customer Data", - description: - "Returns customer data with segment information for visualization. Optionally filter by segment.", - inputSchema: GetCustomerDataInputSchema, - outputSchema: GetCustomerDataOutputSchema, - _meta: { ui: { resourceUri } }, - }, - async ({ segment }): Promise => { - const data = getCustomerData(segment); + server.registerTool( + "get-customer-data", + { + title: "Get Customer Data", + description: + "Returns customer data with segment information for visualization. Optionally filter by segment.", + inputSchema: GetCustomerDataInputSchema.shape, + _meta: { [RESOURCE_URI_META_KEY]: resourceUri }, + }, + async ({ segment }): Promise => { + const data = getCustomerData(segment); - return { - content: [{ type: "text", text: JSON.stringify(data) }], - structuredContent: data, - }; - }, - ); + return { + content: [{ type: "text", text: JSON.stringify(data) }], + }; + }, + ); - registerAppResource( - server, - resourceUri, - resourceUri, - { - mimeType: RESOURCE_MIME_TYPE, - description: "Customer Segmentation Explorer UI", - }, - async (): Promise => { - const html = await fs.readFile( - path.join(DIST_DIR, "mcp-app.html"), - "utf-8", - ); + server.registerResource( + resourceUri, + resourceUri, + { description: "Customer Segmentation Explorer UI" }, + 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; + return { + contents: [ + { + uri: resourceUri, + mimeType: "text/html;profile=mcp-app", + text: html, + }, + ], + }; + }, + ); } + +async function main() { + if (process.argv.includes("--stdio")) { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("Customer Segmentation Server running in stdio mode"); + } else { + const app = express(); + app.use(cors()); + app.use(express.json()); + + app.post("/mcp", async (req: Request, res: Response) => { + try { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + res.on("close", () => { + transport.close(); + }); + + await server.connect(transport); + + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("Error handling MCP request:", 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("Error starting server:", err); + process.exit(1); + } + console.log( + `Customer Segmentation Server listening on http://localhost:${PORT}/mcp`, + ); + }); + + function shutdown() { + console.log("\nShutting down..."); + httpServer.close(() => { + console.log("Server closed"); + process.exit(0); + }); + } + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + } +} + +main().catch(console.error); diff --git a/examples/customer-segmentation-server/src/data-generator.ts b/examples/customer-segmentation-server/src/data-generator.ts index 35e01a7..10cfd06 100644 --- a/examples/customer-segmentation-server/src/data-generator.ts +++ b/examples/customer-segmentation-server/src/data-generator.ts @@ -1,5 +1,5 @@ -import type { Customer, SegmentSummary, SegmentName } from "./types.js"; -import { SEGMENT_COLORS, SEGMENTS } from "./types.js"; +import type { Customer, SegmentSummary, SegmentName } from "./types.ts"; +import { SEGMENT_COLORS, SEGMENTS } from "./types.ts"; // Company name generation const PREFIXES = [ diff --git a/examples/customer-segmentation-server/src/mcp-app.css b/examples/customer-segmentation-server/src/mcp-app.css index bfb5e17..4bdede2 100644 --- a/examples/customer-segmentation-server/src/mcp-app.css +++ b/examples/customer-segmentation-server/src/mcp-app.css @@ -1,56 +1,42 @@ -/* Default fallback values for host style variables */ :root { - color-scheme: light dark; + --color-bg: #ffffff; + --color-text: #1f2937; + --color-text-muted: #6b7280; + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + --color-card-bg: #f9fafb; + --color-border: #e5e7eb; + --color-enterprise: #1e40af; + --color-midmarket: #0d9488; + --color-smb: #059669; + --color-startup: #6366f1; +} - /* Font families */ - --font-sans: system-ui, -apple-system, sans-serif; - - /* Background colors */ - --color-background-primary: light-dark(#ffffff, #111827); - --color-background-secondary: light-dark(#f9fafb, #1f2937); - --color-background-tertiary: light-dark(#f3f4f6, #374151); - - /* Text colors */ - --color-text-primary: light-dark(#1f2937, #f9fafb); - --color-text-secondary: light-dark(#6b7280, #9ca3af); - --color-text-tertiary: light-dark(#9ca3af, #6b7280); - - /* Border colors */ - --color-border-primary: light-dark(#e5e7eb, #374151); - --color-border-secondary: light-dark(#d1d5db, #4b5563); - - /* Ring colors */ - --color-ring-info: light-dark(#2563eb, #3b82f6); - - /* Border radius */ - --border-radius-sm: 6px; - --border-radius-md: 8px; - --border-radius-full: 9999px; - - /* Border width */ - --border-width-regular: 1px; - - /* Shadows */ - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1); - - /* App-specific colors (not from host) */ - --color-enterprise: light-dark(#1e40af, #3b82f6); - --color-midmarket: light-dark(#0d9488, #14b8a6); - --color-smb: light-dark(#059669, #10b981); - --color-startup: light-dark(#6366f1, #818cf8); +@media (prefers-color-scheme: dark) { + :root { + --color-bg: #111827; + --color-text: #f9fafb; + --color-text-muted: #9ca3af; + --color-primary: #3b82f6; + --color-primary-hover: #60a5fa; + --color-card-bg: #1f2937; + --color-border: #374151; + --color-enterprise: #3b82f6; + --color-midmarket: #14b8a6; + --color-smb: #10b981; + --color-startup: #818cf8; + } } html, body { margin: 0; padding: 0; - font-family: var(--font-sans); - color: var(--color-text-primary); + background: var(--color-bg); + color: var(--color-text); overflow: hidden; } .main { - border-radius: var(--border-radius-md); - background: var(--color-background-primary); width: 100%; height: 600px; margin: 0 auto; @@ -99,21 +85,21 @@ html, body { gap: 6px; font-size: 0.8125rem; font-weight: 500; - color: var(--color-text-secondary); + color: var(--color-text-muted); } .select { padding: 4px 8px; font-size: 0.8125rem; - border: var(--border-width-regular) solid var(--color-border-primary); - border-radius: var(--border-radius-sm); - background: var(--color-background-secondary); - color: var(--color-text-primary); + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-card-bg); + color: var(--color-text); cursor: pointer; } .select:focus { - outline: 2px solid var(--color-ring-info); + outline: 2px solid var(--color-primary); outline-offset: 1px; } @@ -121,10 +107,10 @@ html, body { .chart-section { flex: 1; min-height: 0; - background: var(--color-background-secondary); - border-radius: var(--border-radius-md); + background: var(--color-card-bg); + border-radius: 8px; padding: 8px; - border: var(--border-width-regular) solid var(--color-border-primary); + border: 1px solid var(--color-border); } .chart-container { @@ -154,15 +140,15 @@ html, body { font-size: 0.75rem; cursor: pointer; padding: 4px 10px; - border-radius: var(--border-radius-full); - border: var(--border-width-regular) solid var(--color-border-primary); - background: var(--color-background-secondary); + border-radius: 16px; + border: 1px solid var(--color-border); + background: var(--color-card-bg); transition: all 0.15s ease; } .legend-item:hover { - border-color: var(--color-text-secondary); - box-shadow: var(--shadow-sm); + border-color: var(--color-text-muted); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } .legend-item.hidden { @@ -185,7 +171,7 @@ html, body { } .legend-count { - color: var(--color-text-secondary); + color: var(--color-text-muted); } /* Detail section - ~44px */ @@ -200,15 +186,15 @@ html, body { justify-content: center; gap: 16px; height: 100%; - background: var(--color-background-secondary); - border-radius: var(--border-radius-sm); + background: var(--color-card-bg); + border-radius: 6px; padding: 0 12px; - border: var(--border-width-regular) solid var(--color-border-primary); + border: 1px solid var(--color-border); font-size: 0.8125rem; } .detail-placeholder { - color: var(--color-text-secondary); + color: var(--color-text-muted); } .detail-name { @@ -217,7 +203,7 @@ html, body { .detail-segment { padding: 2px 8px; - border-radius: var(--border-radius-sm); + border-radius: 4px; font-size: 0.75rem; font-weight: 500; color: white; @@ -229,10 +215,10 @@ html, body { .detail-segment.startup { background: var(--color-startup); } .detail-metric { - color: var(--color-text-secondary); + color: var(--color-text-muted); } .detail-metric strong { - color: var(--color-text-primary); + color: var(--color-text); font-weight: 600; } diff --git a/examples/customer-segmentation-server/src/mcp-app.ts b/examples/customer-segmentation-server/src/mcp-app.ts index bc7c7e0..757937c 100644 --- a/examples/customer-segmentation-server/src/mcp-app.ts +++ b/examples/customer-segmentation-server/src/mcp-app.ts @@ -1,18 +1,12 @@ /** * @file Customer Segmentation Explorer - interactive scatter/bubble visualization */ -import { - App, - applyHostStyleVariables, - applyHostFonts, - applyDocumentTheme, - type McpUiHostContext, -} from "@modelcontextprotocol/ext-apps"; +import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps"; import { Chart, registerables } from "chart.js"; import "./global.css"; import "./mcp-app.css"; -import type { Customer, SegmentSummary, MetricName } from "./types.js"; -import { SEGMENT_COLORS, METRIC_LABELS } from "./types.js"; +import type { Customer, SegmentSummary, MetricName } from "./types.ts"; +import { SEGMENT_COLORS, METRIC_LABELS } from "./types.ts"; // Register Chart.js components Chart.register(...registerables); @@ -23,7 +17,6 @@ const log = { }; // DOM element references -const mainEl = document.querySelector(".main") as HTMLElement; const xAxisSelect = document.getElementById("x-axis") as HTMLSelectElement; const yAxisSelect = document.getElementById("y-axis") as HTMLSelectElement; const sizeMetricSelect = document.getElementById( @@ -145,40 +138,11 @@ function buildDatasets(): Chart["data"]["datasets"] { }); } -// Hidden element for resolving CSS color values (reused to avoid DOM thrashing) -let colorResolver: HTMLDivElement | null = null; - -// Resolve a CSS color value (handles light-dark() function) -function resolveColor(cssValue: string, fallback: string): string { - if (!cssValue) return fallback; - // If it's a simple color value, return it directly - if (!cssValue.includes("light-dark(")) return cssValue; - // Create resolver element once and keep it hidden - if (!colorResolver) { - colorResolver = document.createElement("div"); - colorResolver.style.position = "absolute"; - colorResolver.style.visibility = "hidden"; - colorResolver.style.pointerEvents = "none"; - document.body.appendChild(colorResolver); - } - colorResolver.style.color = cssValue; - return getComputedStyle(colorResolver).color || fallback; -} - -// Get colors from CSS variables -function getChartColors(): { textColor: string; gridColor: string } { - const style = getComputedStyle(document.documentElement); - const rawTextColor = style.getPropertyValue("--color-text-secondary").trim(); - const rawGridColor = style.getPropertyValue("--color-border-primary").trim(); - return { - textColor: resolveColor(rawTextColor, "#6b7280"), - gridColor: resolveColor(rawGridColor, "#e5e7eb"), - }; -} - // Initialize Chart.js function initChart(): Chart { - const { textColor, gridColor } = getChartColors(); + const isDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches; + const textColor = isDarkMode ? "#9ca3af" : "#6b7280"; + const gridColor = isDarkMode ? "#374151" : "#e5e7eb"; return new Chart(chartCanvas, { type: "bubble", @@ -279,7 +243,8 @@ function initChart(): Chart { function updateChart(): void { if (!state.chart) return; - const { textColor, gridColor } = getChartColors(); + const isDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches; + const textColor = isDarkMode ? "#9ca3af" : "#6b7280"; state.chart.data.datasets = buildDatasets(); @@ -287,13 +252,11 @@ function updateChart(): void { const scales = state.chart.options.scales as { x: { title: { text: string; color: string }; - ticks: { color: string; callback: (value: number) => string }; - grid: { color: string }; + ticks: { callback: (value: number) => string }; }; y: { title: { text: string; color: string }; - ticks: { color: string; callback: (value: number) => string }; - grid: { color: string }; + ticks: { callback: (value: number) => string }; }; }; @@ -301,12 +264,8 @@ function updateChart(): void { scales.y.title.text = METRIC_LABELS[state.yAxis]; scales.x.title.color = textColor; scales.y.title.color = textColor; - scales.x.ticks.color = textColor; - scales.y.ticks.color = textColor; scales.x.ticks.callback = (value: number) => formatValue(value, state.xAxis); scales.y.ticks.callback = (value: number) => formatValue(value, state.yAxis); - scales.x.grid.color = gridColor; - scales.y.grid.color = gridColor; state.chart.update(); } @@ -429,59 +388,20 @@ document.addEventListener("click", (e) => { } }); -// Handle system theme changes (fallback when host doesn't provide styles) +// Handle theme changes window .matchMedia("(prefers-color-scheme: dark)") - .addEventListener("change", (e) => { - // Only apply if we haven't received host theme - if (!app.getHostContext()?.theme) { - applyDocumentTheme(e.matches ? "dark" : "light"); - if (state.chart) { - state.chart.destroy(); - state.chart = initChart(); - } + .addEventListener("change", () => { + if (state.chart) { + state.chart.destroy(); + state.chart = initChart(); } }); -// Apply initial theme based on system preference (before host context is available) -const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches; -applyDocumentTheme(systemDark ? "dark" : "light"); - // Register handlers and connect app.onerror = log.error; -// Handle host context changes (theme, styles, and fonts from host) -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`; - } - // Recreate chart to pick up new colors - if (state.chart && (ctx.theme || ctx.styles?.variables)) { - state.chart.destroy(); - state.chart = initChart(); - } -} - -app.onhostcontextchanged = handleHostContextChanged; - -app.connect().then(() => { - const ctx = app.getHostContext(); - if (ctx) { - handleHostContextChanged(ctx); - } -}); +app.connect(new PostMessageTransport(window.parent)); // Fetch data after connection setTimeout(fetchData, 100); diff --git a/examples/customer-segmentation-server/tsconfig.json b/examples/customer-segmentation-server/tsconfig.json index b028ff5..535267b 100644 --- a/examples/customer-segmentation-server/tsconfig.json +++ b/examples/customer-segmentation-server/tsconfig.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "types": ["node", "vite/client"], "target": "ESNext", "lib": ["ESNext", "DOM", "DOM.Iterable"], "module": "ESNext", diff --git a/examples/customer-segmentation-server/tsconfig.server.json b/examples/customer-segmentation-server/tsconfig.server.json deleted file mode 100644 index 27eb28f..0000000 --- a/examples/customer-segmentation-server/tsconfig.server.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "types": ["node"], - "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/examples/debug-server/.gitignore b/examples/debug-server/.gitignore deleted file mode 100644 index b947077..0000000 --- a/examples/debug-server/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -dist/ diff --git a/examples/debug-server/README.md b/examples/debug-server/README.md deleted file mode 100644 index 89d9a7d..0000000 --- a/examples/debug-server/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# Debug Server - -A comprehensive testing/debugging tool for the MCP Apps SDK that exercises every capability, callback, and result format combination. - -## Tools - -### debug-tool - -Configurable tool for testing all result variations: - -| Parameter | Type | Default | Description | -| -------------------------- | ----------------------------------------------------------------------------------- | -------- | ------------------------------------------- | -| `contentType` | `"text"` \| `"image"` \| `"audio"` \| `"resource"` \| `"resourceLink"` \| `"mixed"` | `"text"` | Content block type to return | -| `multipleBlocks` | boolean | `false` | Return 3 content blocks | -| `includeStructuredContent` | boolean | `true` | Include structuredContent in result | -| `includeMeta` | boolean | `false` | Include \_meta in result | -| `largeInput` | string | - | Large text input (tests tool-input-partial) | -| `simulateError` | boolean | `false` | Return isError: true | -| `delayMs` | number | - | Delay before response (ms) | - -### debug-refresh - -App-only tool (hidden from model) for polling server state. Returns current timestamp and call counter. - -## App UI - -The debug app provides a dashboard with: - -- **Event Log**: Real-time log of all SDK events with filtering -- **Host Info**: Context, capabilities, container dimensions, styles -- **Callback Status**: Table of all callbacks with call counts -- **Actions**: Buttons to test every SDK method: - - Send messages (text/image) - - Logging (debug/info/warning/error) - - Model context updates - - Display mode requests - - Link opening - - Resize controls - - Server tool calls - - File operations - -## Usage - -```bash -# Build -npm run --workspace examples/debug-server build - -# Run standalone -npm run --workspace examples/debug-server serve - -# Run with all examples -npm start -``` - -Then open `http://localhost:8080/basic-host/` and select "Debug MCP App Server" from the dropdown. diff --git a/examples/debug-server/grid-cell.png b/examples/debug-server/grid-cell.png deleted file mode 100644 index 2f1814f..0000000 Binary files a/examples/debug-server/grid-cell.png and /dev/null differ diff --git a/examples/debug-server/main.ts b/examples/debug-server/main.ts deleted file mode 100644 index 9fb0825..0000000 --- a/examples/debug-server/main.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Entry point for running the MCP server. - * Run with: npx mcp-server-debug - * Or: node dist/index.js [--stdio] - */ - -import { createMcpExpressApp } from "@modelcontextprotocol/express"; -import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node"; -import type { McpServer } from "@modelcontextprotocol/server"; -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; -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. - * - * @param createServer - Factory function that creates a new McpServer instance per request. - */ -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 NodeStreamableHTTPServerTransport({ - 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. - * - * @param createServer - Factory function that creates a new McpServer instance. - */ -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/examples/debug-server/mcp-app.html b/examples/debug-server/mcp-app.html deleted file mode 100644 index 63c3444..0000000 --- a/examples/debug-server/mcp-app.html +++ /dev/null @@ -1,210 +0,0 @@ - - - - - - - Debug App - - -
- -
-

- Event Log -
- - -
-

-
-
- - -
-

- Host Info - โ–ผ -

-
-
-
-

Context

-
-
-
-

Capabilities

-
-
-
-

Container

-
-
-
-

Styles Sample

-
-
-
-
-
- - -
-

- Callback Status - โ–ผ -

-
- - - - - - - - - - -
CallbackRegisteredCountLast Payload
-
-
- - -
-

- Actions - โ–ผ -

-
- -
-

Messages

-
- - -
-
- -
-
- - -
-

Logging

-
- -
-
- - - - -
-
- - -
-

Model Context

-
- - -
-
- - -
-
- - -
-

Display Mode

-
- - - -
-
- - -
-

Links

-
- - -
-
- - -
-

Size

-
- -
-
- - - -
-
- Current: measuring... -
-
- - -
-

Server Tools

-
-
- - -
-
- -
-
- -
-
- -
-
- -
-
- - -
-
-
- -
-
- -
-
- -
-
-
- - - diff --git a/examples/debug-server/package.json b/examples/debug-server/package.json deleted file mode 100644 index e28f4d3..0000000 --- a/examples/debug-server/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "@modelcontextprotocol/server-debug", - "version": "2.0.0", - "type": "module", - "description": "Debug MCP App Server for testing all SDK capabilities", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", - "directory": "examples/debug-server" - }, - "license": "MIT", - "main": "dist/server.js", - "types": "dist/server.d.ts", - "bin": { - "mcp-server-debug": "dist/index.js" - }, - "files": [ - "dist" - ], - "exports": { - ".": { - "types": "./dist/server.d.ts", - "default": "./dist/server.js" - } - }, - "scripts": { - "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"", - "watch": "cross-env INPUT=mcp-app.html vite build --watch", - "serve": "bun --watch main.ts", - "serve:stdio": "bun main.ts --stdio", - "start": "cross-env NODE_ENV=development npm run build && npm run serve", - "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio", - "dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"", - "prepublishOnly": "npm run build" - }, - "dependencies": { - "@modelcontextprotocol/express": "2.0.0", - "@modelcontextprotocol/ext-apps": "^2.0.0", - "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", - "zod": "^4.2.0" - }, - "devDependencies": { - "@types/cors": "^2.8.19", - "@types/express": "^5.0.0", - "@types/node": "22.10.0", - "concurrently": "^9.2.1", - "cors": "^2.8.5", - "cross-env": "^10.1.0", - "express": "^5.1.0", - "typescript": "^5.9.3", - "vite": "^6.0.0", - "vite-plugin-singlefile": "^2.3.0" - } -} diff --git a/examples/debug-server/screenshot.png b/examples/debug-server/screenshot.png deleted file mode 100644 index 869fec0..0000000 Binary files a/examples/debug-server/screenshot.png and /dev/null differ diff --git a/examples/debug-server/server.ts b/examples/debug-server/server.ts deleted file mode 100644 index 068f94d..0000000 --- a/examples/debug-server/server.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { - registerAppResource, - registerAppTool, - RESOURCE_MIME_TYPE, -} from "@modelcontextprotocol/ext-apps/server"; -import { - McpServer, - type CallToolResult, - type ReadResourceResult, -} from "@modelcontextprotocol/server"; -import fs from "node:fs/promises"; -import { appendFileSync } from "node:fs"; -import path from "node:path"; -import { z } from "zod"; - -// 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; - -// Track call counter across requests (stateful for demo purposes) -let callCounter = 0; - -// Parse --log-file argument or use default -const DEFAULT_LOG_FILE = "/tmp/mcp-apps-debug-server.log"; -function getLogFilePath(): string { - const logFileArg = process.argv.find((arg) => arg.startsWith("--log-file=")); - if (logFileArg) { - return logFileArg.split("=")[1]; - } - return process.env.DEBUG_LOG_FILE ?? DEFAULT_LOG_FILE; -} - -const logFilePath = getLogFilePath(); - -/** - * Append a log entry to the log file - */ -function appendToLogFile(entry: { - timestamp: string; - type: string; - payload: unknown; -}): void { - try { - const line = JSON.stringify(entry) + "\n"; - appendFileSync(logFilePath, line, "utf-8"); - } catch (e) { - console.error("[debug-server] Failed to write to log file:", e); - } -} - -// Minimal 1x1 blue PNG (base64) -const BLUE_PNG_1X1 = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPj/HwADBwIAMCbHYQAAAABJRU5ErkJggg=="; - -// Minimal silent WAV (base64) - 44 byte header + 1 sample -const SILENT_WAV = - "UklGRiYAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQIAAAAAAA=="; - -/** - * Input schema for the debug-tool - */ -const DebugInputSchema = z.object({ - // Content configuration - contentType: z - .enum(["text", "image", "audio", "resource", "resourceLink", "mixed"]) - .default("text"), - multipleBlocks: z.boolean().default(true), - includeStructuredContent: z.boolean().default(true), - includeMeta: z.boolean().default(true), - - // Streaming test (large input) - largeInput: z.string().optional(), - - // Error/delay simulation - simulateError: z.boolean().default(false), - delayMs: z.number().optional(), -}); - -type DebugInput = z.infer; - -/** - * Output schema for structured content - */ -const DebugOutputSchema = z.object({ - config: z.record(z.string(), z.unknown()), - timestamp: z.string(), - counter: z.number(), - largeInputLength: z.number().optional(), -}); - -/** - * Builds content blocks based on configuration - */ -function buildContent(args: DebugInput): CallToolResult["content"] { - const count = args.multipleBlocks ? 3 : 1; - const content: CallToolResult["content"] = []; - - for (let i = 0; i < count; i++) { - const suffix = args.multipleBlocks ? ` #${i + 1}` : ""; - - switch (args.contentType) { - case "text": - content.push({ type: "text", text: `Debug text content${suffix}` }); - break; - case "image": - content.push({ - type: "image", - data: BLUE_PNG_1X1, - mimeType: "image/png", - }); - break; - case "audio": - content.push({ - type: "audio", - data: SILENT_WAV, - mimeType: "audio/wav", - }); - break; - case "resource": - content.push({ - type: "resource", - resource: { - uri: `debug://embedded-resource${suffix.replace(/\s/g, "-")}`, - text: `Embedded resource content${suffix}`, - mimeType: "text/plain", - }, - }); - break; - case "resourceLink": - content.push({ - type: "resource_link", - uri: `debug://linked-resource${suffix.replace(/\s/g, "-")}`, - name: `Linked Resource${suffix}`, - mimeType: "text/plain", - }); - break; - case "mixed": - // Return one of each type (ignore multipleBlocks for mixed) - return [ - { type: "text", text: "Mixed content: text block" }, - { type: "image", data: BLUE_PNG_1X1, mimeType: "image/png" }, - { type: "audio", data: SILENT_WAV, mimeType: "audio/wav" }, - ]; - } - } - - return content; -} - -/** - * Creates a new MCP server instance with debug tools registered. - */ -export function createServer(): McpServer { - const server = new McpServer({ - name: "Debug MCP App Server", - version: "1.0.0", - }); - - const resourceUri = "ui://debug-tool/mcp-app.html"; - - // Main debug tool - exercises all result variations - registerAppTool( - server, - "debug-tool", - { - title: "Debug Tool", - description: - "Comprehensive debug tool for testing MCP Apps SDK. Configure content types, error simulation, delays, and more.", - inputSchema: DebugInputSchema, - outputSchema: DebugOutputSchema, - _meta: { ui: { resourceUri } }, - }, - async (args): Promise => { - // Apply delay if requested - if (args.delayMs && args.delayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, args.delayMs)); - } - - // Build content based on config - const content = buildContent(args); - - // Build result - const result: CallToolResult = { content }; - - // Add structured content if requested - if (args.includeStructuredContent) { - result.structuredContent = { - config: args, - timestamp: new Date().toISOString(), - counter: ++callCounter, - ...(args.largeInput - ? { largeInputLength: args.largeInput.length } - : {}), - }; - } - - // Add _meta if requested - if (args.includeMeta) { - result._meta = { - debugInfo: { - processedAt: Date.now(), - serverVersion: "1.0.0", - }, - }; - } - - // Set error flag if requested - if (args.simulateError) { - result.isError = true; - } - - return result; - }, - ); - - // App-only refresh tool (hidden from model) - registerAppTool( - server, - "debug-refresh", - { - title: "Refresh Debug Info", - description: - "App-only tool for polling server state. Not visible to the model.", - inputSchema: z.object({}), - outputSchema: z.object({ timestamp: z.string(), counter: z.number() }), - _meta: { - ui: { - resourceUri, - visibility: ["app"], - }, - }, - }, - async (): Promise => { - const timestamp = new Date().toISOString(); - return { - content: [{ type: "text", text: `Server timestamp: ${timestamp}` }], - structuredContent: { timestamp, counter: callCounter }, - }; - }, - ); - - // App-only log tool - writes events to log file - registerAppTool( - server, - "debug-log", - { - title: "Log to File", - description: - "App-only tool for logging events to the server log file. Not visible to the model.", - inputSchema: z.object({ - type: z.string(), - payload: z.unknown(), - }), - outputSchema: z.object({ logged: z.boolean(), logFile: z.string() }), - _meta: { - ui: { - resourceUri, - visibility: ["app"], - }, - }, - }, - async (args): Promise => { - const timestamp = new Date().toISOString(); - appendToLogFile({ timestamp, type: args.type, payload: args.payload }); - return { - content: [{ type: "text", text: `Logged to ${logFilePath}` }], - structuredContent: { logged: true, logFile: logFilePath }, - }; - }, - ); - - // 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/examples/debug-server/src/global.css b/examples/debug-server/src/global.css deleted file mode 100644 index 1886326..0000000 --- a/examples/debug-server/src/global.css +++ /dev/null @@ -1,33 +0,0 @@ -* { - box-sizing: border-box; -} - -html, body { - font-family: system-ui, -apple-system, sans-serif; - font-size: 14px; - margin: 0; - padding: 0; -} - -code { - font-family: ui-monospace, "SF Mono", Monaco, "Cascadia Code", Consolas, monospace; - font-size: 0.9em; - background: rgba(0, 0, 0, 0.05); - padding: 0.1em 0.3em; - border-radius: 3px; -} - -@media (prefers-color-scheme: dark) { - code { - background: rgba(255, 255, 255, 0.1); - } -} - -button { - cursor: pointer; -} - -input, select, button { - font-family: inherit; - font-size: inherit; -} diff --git a/examples/debug-server/src/mcp-app.css b/examples/debug-server/src/mcp-app.css deleted file mode 100644 index 79a74ed..0000000 --- a/examples/debug-server/src/mcp-app.css +++ /dev/null @@ -1,367 +0,0 @@ -.main { - --color-primary: #2563eb; - --color-primary-hover: #1d4ed8; - --color-success: #16a34a; - --color-warning: #ca8a04; - --color-error: #dc2626; - --color-border: #e5e7eb; - --color-bg-subtle: #f9fafb; - - width: 100%; - max-width: 800px; - padding: 1rem; - margin: 0 auto; -} - -@media (prefers-color-scheme: dark) { - .main { - --color-border: #374151; - --color-bg-subtle: #1f2937; - } -} - -/* Section styling */ -.section { - border: 1px solid var(--color-border); - border-radius: 8px; - margin-bottom: 1rem; - overflow: hidden; -} - -.section-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.75rem 1rem; - margin: 0; - font-size: 1rem; - font-weight: 600; - background: var(--color-bg-subtle); - border-bottom: 1px solid var(--color-border); -} - -.collapsible .section-header { - cursor: pointer; - user-select: none; -} - -.collapsible .section-header:hover { - background: var(--color-border); -} - -.toggle-icon { - transition: transform 0.2s; -} - -.collapsed .toggle-icon { - transform: rotate(-90deg); -} - -.collapsed .section-content { - display: none; -} - -.section-content { - padding: 1rem; -} - -.header-actions { - display: flex; - gap: 0.5rem; - align-items: center; -} - -/* Event log */ -.event-log { - max-height: 200px; - overflow-y: auto; - padding: 0.5rem; - font-family: ui-monospace, monospace; - font-size: 0.85rem; - background: var(--color-bg-subtle); -} - -.log-entry { - padding: 0.25rem 0; - border-bottom: 1px solid var(--color-border); - cursor: pointer; -} - -.log-entry:hover { - background: var(--color-bg-hover, rgba(0, 0, 0, 0.03)); -} - -.log-entry:last-child { - border-bottom: none; -} - -.log-entry-header { - display: flex; - align-items: center; - gap: 0.25rem; -} - -.log-toggle { - color: #9ca3af; - font-size: 0.7rem; - width: 1rem; - transition: transform 0.15s ease; - user-select: none; -} - -.log-entry.expanded .log-toggle { - transform: rotate(90deg); -} - -.log-time { - color: #6b7280; - margin-right: 0.5rem; -} - -.log-type { - font-weight: 600; - margin-right: 0.5rem; -} - -.log-type.ontoolinput { color: var(--color-primary); } -.log-type.ontoolinputpartial { color: #8b5cf6; } -.log-type.ontoolresult { color: var(--color-success); } -.log-type.ontoolcancelled { color: var(--color-warning); } -.log-type.onhostcontextchanged { color: #7c3aed; } -.log-type.onteardown { color: #f97316; } -.log-type.oncalltool { color: #ec4899; } -.log-type.onlisttools { color: #14b8a6; } -.log-type.onerror { color: var(--color-error); } -.log-type.connected { color: var(--color-success); } -.log-type.update-context { color: #0891b2; } - -.log-payload-preview { - color: #6b7280; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex: 1; - min-width: 0; -} - -.log-payload-full { - display: none; - margin: 0.5rem 0 0.25rem 1.25rem; - padding: 0.5rem; - background: var(--color-bg-subtle, #f3f4f6); - border-radius: 4px; - font-size: 0.8rem; - overflow-x: auto; - white-space: pre-wrap; - word-break: break-all; - max-height: 300px; - overflow-y: auto; -} - -.log-entry.expanded .log-payload-full { - display: block; -} - -/* Info grid */ -.info-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 1rem; -} - -.info-group h3 { - margin: 0 0 0.5rem 0; - font-size: 0.9rem; - color: #6b7280; -} - -.info-group dl { - margin: 0; - font-size: 0.85rem; -} - -.info-group dt { - font-weight: 600; - color: #374151; -} - -.info-group dd { - margin: 0 0 0.5rem 0; - color: #6b7280; -} - -@media (prefers-color-scheme: dark) { - .info-group dt { color: #d1d5db; } - .info-group dd { color: #9ca3af; } -} - -.styles-sample { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; -} - -.style-swatch { - width: 24px; - height: 24px; - border-radius: 4px; - border: 1px solid var(--color-border); - display: flex; - align-items: center; - justify-content: center; - font-size: 10px; - color: white; - text-shadow: 0 0 2px black; -} - -/* Callback table */ -.callback-table { - width: 100%; - border-collapse: collapse; - font-size: 0.85rem; -} - -.callback-table th, -.callback-table td { - padding: 0.5rem; - text-align: left; - border-bottom: 1px solid var(--color-border); -} - -.callback-table th { - background: var(--color-bg-subtle); - font-weight: 600; -} - -.callback-table .registered-yes { - color: var(--color-success); -} - -.callback-table .registered-no { - color: #9ca3af; -} - -.callback-table .payload-preview { - max-width: 200px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - cursor: pointer; - font-family: ui-monospace, monospace; - font-size: 0.8rem; -} - -.callback-table .payload-preview:hover { - white-space: normal; - word-break: break-all; -} - -/* Action groups */ -.action-group { - margin-bottom: 1.5rem; -} - -.action-group:last-child { - margin-bottom: 0; -} - -.action-group h3 { - margin: 0 0 0.75rem 0; - font-size: 0.9rem; - font-weight: 600; - color: #374151; - border-bottom: 1px solid var(--color-border); - padding-bottom: 0.25rem; -} - -@media (prefers-color-scheme: dark) { - .action-group h3 { color: #d1d5db; } -} - -.action-row { - display: flex; - gap: 0.5rem; - align-items: center; - margin-bottom: 0.5rem; -} - -.action-row:last-child { - margin-bottom: 0; -} - -.action-row input[type="text"], -.action-row input[type="url"], -.action-row input[type="number"] { - flex: 1; - padding: 0.5rem; - border: 1px solid var(--color-border); - border-radius: 4px; - background: transparent; -} - -.action-row input[type="file"] { - flex: 1; -} - -.action-row button { - padding: 0.5rem 1rem; - border: none; - border-radius: 4px; - background: var(--color-primary); - color: white; - font-weight: 500; -} - -.action-row button:hover { - background: var(--color-primary-hover); -} - -.btn-row { - flex-wrap: wrap; -} - -.btn-small { - padding: 0.375rem 0.75rem !important; - font-size: 0.85rem; -} - -/* Tool config */ -.tool-config { - background: var(--color-bg-subtle); - padding: 0.75rem; - border-radius: 4px; - margin-bottom: 0.75rem; -} - -.config-row { - display: flex; - align-items: center; - gap: 0.5rem; - margin-bottom: 0.5rem; -} - -.config-row:last-child { - margin-bottom: 0; -} - -.config-row label { - min-width: 120px; - font-size: 0.85rem; -} - -.config-row select, -.config-row input[type="number"] { - padding: 0.25rem 0.5rem; - border: 1px solid var(--color-border); - border-radius: 4px; - background: transparent; -} - -/* Filter select */ -#log-filter { - padding: 0.25rem 0.5rem; - border: 1px solid var(--color-border); - border-radius: 4px; - background: transparent; - font-size: 0.85rem; -} diff --git a/examples/debug-server/src/mcp-app.ts b/examples/debug-server/src/mcp-app.ts deleted file mode 100644 index b1c862d..0000000 --- a/examples/debug-server/src/mcp-app.ts +++ /dev/null @@ -1,642 +0,0 @@ -/** - * @file Debug App - Comprehensive testing/debugging tool for the MCP Apps SDK. - * - * This app exercises every capability, callback, and result format combination. - */ -import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; -import "./global.css"; -import "./mcp-app.css"; - -// ============================================================================ -// Types -// ============================================================================ - -interface LogEntry { - time: number; - type: string; - payload: unknown; -} - -interface AppState { - eventLog: LogEntry[]; - callbackCounts: Map; - lastPayloads: Map; - autoResizeCleanup: (() => void) | null; - logFilter: string; -} - -// ============================================================================ -// State -// ============================================================================ - -const state: AppState = { - eventLog: [], - callbackCounts: new Map(), - lastPayloads: new Map(), - autoResizeCleanup: null, - logFilter: "all", -}; - -// Callbacks we track -const CALLBACKS = [ - "ontoolinput", - "ontoolinputpartial", - "ontoolresult", - "ontoolcancelled", - "onhostcontextchanged", - "onteardown", - "oncalltool", - "onlisttools", - "onerror", -] as const; - -// ============================================================================ -// DOM Elements -// ============================================================================ - -const mainEl = document.querySelector(".main") as HTMLElement; -const eventLogEl = document.getElementById("event-log")!; -const logFilterEl = document.getElementById("log-filter") as HTMLSelectElement; -const clearLogBtn = document.getElementById("clear-log-btn")!; - -// Host info -const hostContextInfoEl = document.getElementById("host-context-info")!; -const hostCapabilitiesInfoEl = document.getElementById( - "host-capabilities-info", -)!; -const hostContainerInfoEl = document.getElementById("host-container-info")!; -const hostStylesSampleEl = document.getElementById("host-styles-sample")!; - -// Callback status -const callbackTableBodyEl = document.getElementById("callback-table-body")!; - -// Action elements -const messageTextEl = document.getElementById( - "message-text", -) as HTMLInputElement; -const sendMessageTextBtn = document.getElementById("send-message-text-btn")!; -const sendMessageImageBtn = document.getElementById("send-message-image-btn")!; - -const logDataEl = document.getElementById("log-data") as HTMLInputElement; -const logDebugBtn = document.getElementById("log-debug-btn")!; -const logInfoBtn = document.getElementById("log-info-btn")!; -const logWarningBtn = document.getElementById("log-warning-btn")!; -const logErrorBtn = document.getElementById("log-error-btn")!; - -const contextTextEl = document.getElementById( - "context-text", -) as HTMLInputElement; -const updateContextTextBtn = document.getElementById( - "update-context-text-btn", -)!; -const updateContextStructuredBtn = document.getElementById( - "update-context-structured-btn", -)!; -const updateContextImageBtn = document.getElementById( - "update-context-image-btn", -)!; - -const displayInlineBtn = document.getElementById("display-inline-btn")!; -const displayFullscreenBtn = document.getElementById("display-fullscreen-btn")!; -const displayPipBtn = document.getElementById("display-pip-btn")!; - -const linkUrlEl = document.getElementById("link-url") as HTMLInputElement; -const openLinkBtn = document.getElementById("open-link-btn")!; - -const autoResizeToggleEl = document.getElementById( - "auto-resize-toggle", -) as HTMLInputElement; -const resize200x100Btn = document.getElementById("resize-200x100-btn")!; -const resize400x300Btn = document.getElementById("resize-400x300-btn")!; -const resize800x600Btn = document.getElementById("resize-800x600-btn")!; -const currentSizeEl = document.getElementById("current-size")!; - -// Tool config elements -const toolContentTypeEl = document.getElementById( - "tool-content-type", -) as HTMLSelectElement; -const toolMultipleBlocksEl = document.getElementById( - "tool-multiple-blocks", -) as HTMLInputElement; -const toolStructuredContentEl = document.getElementById( - "tool-structured-content", -) as HTMLInputElement; -const toolIncludeMetaEl = document.getElementById( - "tool-include-meta", -) as HTMLInputElement; -const toolSimulateErrorEl = document.getElementById( - "tool-simulate-error", -) as HTMLInputElement; -const toolDelayMsEl = document.getElementById( - "tool-delay-ms", -) as HTMLInputElement; -const callDebugToolBtn = document.getElementById("call-debug-tool-btn")!; -const callDebugRefreshBtn = document.getElementById("call-debug-refresh-btn")!; - -// ============================================================================ -// Utility Functions -// ============================================================================ - -function formatTime(timestamp: number): string { - const date = new Date(timestamp); - const h = date.getHours().toString().padStart(2, "0"); - const m = date.getMinutes().toString().padStart(2, "0"); - const s = date.getSeconds().toString().padStart(2, "0"); - const ms = date.getMilliseconds().toString().padStart(3, "0"); - return `${h}:${m}:${s}.${ms}`; -} - -function truncatePayload(payload: unknown): string { - const str = JSON.stringify(payload); - if (str.length > 100) { - return str.slice(0, 100) + "..."; - } - return str; -} - -// ============================================================================ -// Rendering Functions -// ============================================================================ - -function renderEventLog(): void { - const filtered = - state.logFilter === "all" - ? state.eventLog - : state.eventLog.filter((e) => e.type === state.logFilter); - - const fullPayload = (payload: unknown) => - JSON.stringify(payload, null, 2) - .replace(//g, ">"); - - eventLogEl.innerHTML = filtered - .map( - (entry, index) => ` -
-
- โ–ถ - [${formatTime(entry.time)}] - ${entry.type}: - ${truncatePayload(entry.payload)} -
-
${fullPayload(entry.payload)}
-
- `, - ) - .join(""); - - // Add click handlers for toggling - eventLogEl.querySelectorAll(".log-entry").forEach((el) => { - el.addEventListener("click", () => { - el.classList.toggle("expanded"); - }); - }); - - // Auto-scroll to bottom - eventLogEl.scrollTop = eventLogEl.scrollHeight; -} - -function renderCallbackStatus(): void { - callbackTableBodyEl.innerHTML = CALLBACKS.map((name) => { - const count = state.callbackCounts.get(name) ?? 0; - const lastPayload = state.lastPayloads.get(name); - const registered = name !== "onerror"; // All callbacks are registered - - return ` - - ${name} - ${registered ? "โœ“" : "โœ—"} - ${count} - ${lastPayload ? truncatePayload(lastPayload) : "-"} - - `; - }).join(""); -} - -function renderHostInfo(): void { - const ctx = app.getHostContext(); - const caps = app.getHostCapabilities(); - const version = app.getHostVersion(); - - // Context info - if (ctx) { - hostContextInfoEl.innerHTML = ` -
Theme
${ctx.theme ?? "unknown"}
-
Locale
${ctx.locale ?? "unknown"}
-
TimeZone
${ctx.timeZone ?? "unknown"}
-
Platform
${ctx.platform ?? "unknown"}
-
Display Mode
${ctx.displayMode ?? "unknown"}
-
Host
${version?.name ?? "unknown"} v${version?.version ?? "?"}
- `; - } else { - hostContextInfoEl.innerHTML = "
No context available
"; - } - - // Capabilities - if (caps) { - hostCapabilitiesInfoEl.innerHTML = ` -
openLinks
${caps.openLinks ? "โœ“" : "โœ—"}
-
serverTools
${caps.serverTools ? "โœ“" : "โœ—"}
-
serverResources
${caps.serverResources ? "โœ“" : "โœ—"}
-
logging
${caps.logging ? "โœ“" : "โœ—"}
-
message
${caps.message ? "โœ“" : "โœ—"}
-
updateModelContext
${caps.updateModelContext ? "โœ“" : "โœ—"}
- `; - } else { - hostCapabilitiesInfoEl.innerHTML = "
No capabilities available
"; - } - - // Container info - if (ctx?.containerDimensions) { - const dims = ctx.containerDimensions; - hostContainerInfoEl.innerHTML = ` -
Width
${"width" in dims ? dims.width + "px" : `max ${dims.maxWidth ?? "?"}px`}
-
Height
${"height" in dims ? dims.height + "px" : `max ${dims.maxHeight ?? "?"}px`}
-
Safe Area
${ctx.safeAreaInsets ? `T${ctx.safeAreaInsets.top} R${ctx.safeAreaInsets.right} B${ctx.safeAreaInsets.bottom} L${ctx.safeAreaInsets.left}` : "none"}
- `; - } else { - hostContainerInfoEl.innerHTML = "
No container info
"; - } - - // Styles sample - if (ctx?.styles) { - const styleVars = Object.entries(ctx.styles).slice(0, 6); - hostStylesSampleEl.innerHTML = styleVars - .map(([key, value]) => { - const color = String(value); - return `
`; - }) - .join(""); - } else { - hostStylesSampleEl.innerHTML = "No styles"; - } -} - -function updateCurrentSize(): void { - const w = document.documentElement.scrollWidth; - const h = document.documentElement.scrollHeight; - currentSizeEl.textContent = `${w}x${h}`; -} - -// ============================================================================ -// Event Logging -// ============================================================================ - -/** - * Send a log entry to the server's debug-log tool (writes to file) - */ -async function sendToServerLog(type: string, payload: unknown): Promise { - try { - await app.callServerTool({ - name: "debug-log", - arguments: { type, payload }, - }); - } catch (e) { - // Log to console only - don't call logEvent to avoid infinite loop - console.error("[debug-app] Failed to send log to server:", e); - } -} - -function logEvent(type: string, payload: unknown): void { - const time = Date.now(); - - // Log to console - console.log(`[debug-app] ${type}:`, payload); - - // Update state - const count = (state.callbackCounts.get(type) ?? 0) + 1; - state.callbackCounts.set(type, count); - state.lastPayloads.set(type, payload); - state.eventLog.push({ time, type, payload }); - - // Keep log manageable (max 100 entries) - if (state.eventLog.length > 100) { - state.eventLog.shift(); - } - - renderEventLog(); - renderCallbackStatus(); - - // Send to server log file (async, fire-and-forget) - // Skip sending debug-log results to avoid noise - if ( - type !== "server-tool-result" || - (payload as { name?: string })?.name !== "debug-log" - ) { - sendToServerLog(type, payload); - } -} - -// ============================================================================ -// Safe Area Handling -// ============================================================================ - -function handleHostContextChanged(ctx: McpUiHostContext): void { - 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`; - } - renderHostInfo(); -} - -// ============================================================================ -// App Instance & Callbacks -// ============================================================================ - -const app = new App( - { name: "Debug App", version: "1.0.0" }, - { tools: { listChanged: true } }, // Declare tools capability for oncalltool/onlisttools - { autoResize: false }, // We'll manage auto-resize ourselves for toggle demo -); - -// Register ALL callbacks BEFORE connecting -app.ontoolinput = (params) => { - logEvent("ontoolinput", params); -}; - -app.ontoolinputpartial = (params) => { - logEvent("ontoolinputpartial", params); -}; - -app.ontoolresult = (result) => { - logEvent("ontoolresult", result); -}; - -app.ontoolcancelled = (params) => { - logEvent("ontoolcancelled", params); -}; - -app.onhostcontextchanged = (ctx) => { - logEvent("onhostcontextchanged", ctx); - handleHostContextChanged(ctx); -}; - -app.onteardown = async (params) => { - logEvent("onteardown", params); - return {}; -}; - -app.oncalltool = async (params) => { - logEvent("oncalltool", params); - return { - content: [{ type: "text", text: "App handled tool call" }], - }; -}; - -app.onlisttools = async (params) => { - logEvent("onlisttools", params); - return { tools: [] }; -}; - -app.onerror = (error) => { - logEvent("onerror", error); -}; - -// ============================================================================ -// Section Collapsing -// ============================================================================ - -document.querySelectorAll(".section-header[data-toggle]").forEach((header) => { - header.addEventListener("click", () => { - const section = header.closest(".section"); - section?.classList.toggle("collapsed"); - }); -}); - -// ============================================================================ -// Event Log Controls -// ============================================================================ - -logFilterEl.addEventListener("change", () => { - state.logFilter = logFilterEl.value; - renderEventLog(); -}); - -clearLogBtn.addEventListener("click", () => { - state.eventLog = []; - renderEventLog(); -}); - -// ============================================================================ -// Message Actions -// ============================================================================ - -sendMessageTextBtn.addEventListener("click", async () => { - try { - const result = await app.sendMessage({ - role: "user", - content: [{ type: "text", text: messageTextEl.value }], - }); - logEvent("send-message-result", result); - } catch (e) { - logEvent("error", e); - } -}); - -sendMessageImageBtn.addEventListener("click", async () => { - // 1x1 red PNG for testing - const redPng = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; - try { - const result = await app.sendMessage({ - role: "user", - content: [{ type: "image", data: redPng, mimeType: "image/png" }], - }); - logEvent("send-message-result", result); - } catch (e) { - logEvent("error", e); - } -}); - -// ============================================================================ -// Logging Actions -// ============================================================================ - -function sendLog(level: "debug" | "info" | "warning" | "error"): void { - app.sendLog({ level, data: logDataEl.value }); - logEvent("send-log", { level, data: logDataEl.value }); -} - -logDebugBtn.addEventListener("click", () => sendLog("debug")); -logInfoBtn.addEventListener("click", () => sendLog("info")); -logWarningBtn.addEventListener("click", () => sendLog("warning")); -logErrorBtn.addEventListener("click", () => sendLog("error")); - -// ============================================================================ -// Model Context Actions -// ============================================================================ - -updateContextTextBtn.addEventListener("click", async () => { - try { - await app.updateModelContext({ - content: [{ type: "text", text: contextTextEl.value }], - }); - logEvent("update-context", { type: "text", value: contextTextEl.value }); - } catch (e) { - logEvent("error", e); - } -}); - -updateContextStructuredBtn.addEventListener("click", async () => { - try { - await app.updateModelContext({ - structuredContent: { - debugState: { - eventCount: state.eventLog.length, - timestamp: new Date().toISOString(), - }, - }, - }); - logEvent("update-context", { type: "structured" }); - } catch (e) { - logEvent("error", e); - } -}); - -updateContextImageBtn.addEventListener("click", async () => { - // 1x1 red PNG for testing - const redPng = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; - try { - await app.updateModelContext({ - content: [{ type: "image", data: redPng, mimeType: "image/png" }], - }); - logEvent("update-context", { type: "image" }); - } catch (e) { - logEvent("error", e); - } -}); - -// ============================================================================ -// Display Mode Actions -// ============================================================================ - -async function requestDisplayMode( - mode: "inline" | "fullscreen" | "pip", -): Promise { - try { - const result = await app.requestDisplayMode({ mode }); - logEvent("display-mode-result", { mode, result }); - } catch (e) { - logEvent("error", e); - } -} - -displayInlineBtn.addEventListener("click", () => requestDisplayMode("inline")); -displayFullscreenBtn.addEventListener("click", () => - requestDisplayMode("fullscreen"), -); -displayPipBtn.addEventListener("click", () => requestDisplayMode("pip")); - -// ============================================================================ -// Link Action -// ============================================================================ - -openLinkBtn.addEventListener("click", async () => { - try { - const result = await app.openLink({ url: linkUrlEl.value }); - logEvent("open-link-result", result); - } catch (e) { - logEvent("error", e); - } -}); - -// ============================================================================ -// Size Controls -// ============================================================================ - -autoResizeToggleEl.addEventListener("change", () => { - if (autoResizeToggleEl.checked) { - if (!state.autoResizeCleanup) { - state.autoResizeCleanup = app.setupSizeChangedNotifications(); - } - } else { - if (state.autoResizeCleanup) { - state.autoResizeCleanup(); - state.autoResizeCleanup = null; - } - } - logEvent("auto-resize-toggle", { enabled: autoResizeToggleEl.checked }); -}); - -function manualResize(width: number, height: number): void { - app.sendSizeChanged({ width, height }); - logEvent("manual-resize", { width, height }); -} - -resize200x100Btn.addEventListener("click", () => manualResize(200, 100)); -resize400x300Btn.addEventListener("click", () => manualResize(400, 300)); -resize800x600Btn.addEventListener("click", () => manualResize(800, 600)); - -// Update current size periodically -setInterval(updateCurrentSize, 1000); - -// ============================================================================ -// Server Tool Actions -// ============================================================================ - -callDebugToolBtn.addEventListener("click", async () => { - const args = { - contentType: toolContentTypeEl.value, - multipleBlocks: toolMultipleBlocksEl.checked, - includeStructuredContent: toolStructuredContentEl.checked, - includeMeta: toolIncludeMetaEl.checked, - simulateError: toolSimulateErrorEl.checked, - delayMs: parseInt(toolDelayMsEl.value, 10) || undefined, - }; - - try { - logEvent("call-server-tool", { name: "debug-tool", arguments: args }); - const result = await app.callServerTool({ - name: "debug-tool", - arguments: args, - }); - logEvent("server-tool-result", result); - } catch (e) { - logEvent("error", e); - } -}); - -callDebugRefreshBtn.addEventListener("click", async () => { - try { - logEvent("call-server-tool", { name: "debug-refresh", arguments: {} }); - const result = await app.callServerTool({ - name: "debug-refresh", - arguments: {}, - }); - logEvent("server-tool-result", result); - } catch (e) { - logEvent("error", e); - } -}); - -// ============================================================================ -// Initialization -// ============================================================================ - -// Initial render -renderCallbackStatus(); - -// Connect to host -app - .connect() - .then(() => { - logEvent("connected", { success: true }); - - const ctx = app.getHostContext(); - if (ctx) { - handleHostContextChanged(ctx); - } - - renderHostInfo(); - updateCurrentSize(); - - // Auto-resize is enabled by default in App, capture cleanup if we want to toggle - // We'll set it up ourselves since we want toggle control - state.autoResizeCleanup = app.setupSizeChangedNotifications(); - }) - .catch((e) => { - logEvent("error", e); - }); diff --git a/examples/debug-server/tsconfig.json b/examples/debug-server/tsconfig.json deleted file mode 100644 index b028ff5..0000000 --- a/examples/debug-server/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "types": ["node", "vite/client"], - "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/examples/debug-server/tsconfig.server.json b/examples/debug-server/tsconfig.server.json deleted file mode 100644 index 27eb28f..0000000 --- a/examples/debug-server/tsconfig.server.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "types": ["node"], - "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/examples/debug-server/vite.config.ts b/examples/debug-server/vite.config.ts deleted file mode 100644 index 6ff6d99..0000000 --- a/examples/debug-server/vite.config.ts +++ /dev/null @@ -1,24 +0,0 @@ -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/examples/integration-server/.gitignore b/examples/integration-server/.gitignore deleted file mode 100644 index b947077..0000000 --- a/examples/integration-server/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -dist/ diff --git a/examples/integration-server/README.md b/examples/integration-server/README.md deleted file mode 100644 index d66ed74..0000000 --- a/examples/integration-server/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# Example: Integration Test Server - -An MCP App example used for E2E integration testing. - -## Overview - -This example demonstrates all App SDK communication APIs and is used by the E2E test suite to verify host-app interactions: - -- Tool registration with a linked UI resource -- React UI using the [`useApp()`](https://apps.extensions.modelcontextprotocol.io/api/functions/_modelcontextprotocol_ext-apps_react.useApp.html) hook -- App communication APIs: [`callServerTool`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#callservertool), [`sendMessage`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendmessage), [`sendLog`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendlog), [`openLink`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#openlink) - -## Key Files - -- [`server.ts`](server.ts) - MCP server with tool and resource registration -- [`mcp-app.html`](mcp-app.html) / [`src/mcp-app.tsx`](src/mcp-app.tsx) - React UI using `useApp()` hook - -## Getting Started - -```bash -npm install -npm run dev -``` - -## How It Works - -1. The server registers a `get-time` tool with metadata linking it to a UI HTML resource (`ui://get-time/mcp-app.html`). -2. When the tool is invoked, the Host renders the UI from the resource. -3. The UI uses the MCP App SDK API to communicate with the host and call server tools. diff --git a/examples/integration-server/main.ts b/examples/integration-server/main.ts deleted file mode 100644 index 9a42842..0000000 --- a/examples/integration-server/main.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Entry point for running the MCP server. - * Run with: npx mcp-integration-server - * Or: node dist/index.js [--stdio] - */ - -import { createMcpExpressApp } from "@modelcontextprotocol/express"; -import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node"; -import type { McpServer } from "@modelcontextprotocol/server"; -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; -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. - * - * @param createServer - Factory function that creates a new McpServer instance per request. - */ -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 NodeStreamableHTTPServerTransport({ - 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. - * - * @param createServer - Factory function that creates a new McpServer instance. - */ -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/examples/integration-server/mcp-app.html b/examples/integration-server/mcp-app.html deleted file mode 100644 index 205ff4e..0000000 --- a/examples/integration-server/mcp-app.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - Get Time App - - - -
- - - diff --git a/examples/integration-server/package.json b/examples/integration-server/package.json deleted file mode 100644 index 7e04f4a..0000000 --- a/examples/integration-server/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "integration-server", - "version": "2.0.0", - "private": true, - "type": "module", - "scripts": { - "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"", - "watch": "cross-env INPUT=mcp-app.html vite build --watch", - "serve:http": "bun --watch main.ts", - "serve:stdio": "bun main.ts --stdio", - "start": "npm run start:http", - "start:http": "cross-env NODE_ENV=development npm run build && npm run serve:http", - "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio", - "dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve:http\"", - "serve": "bun --watch main.ts" - }, - "dependencies": { - "@modelcontextprotocol/client": "2.0.0", - "@modelcontextprotocol/express": "2.0.0", - "@modelcontextprotocol/ext-apps": "^2.0.0", - "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", - "cors": "^2.8.5", - "express": "^5.1.0", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "zod": "^4.2.0" - }, - "devDependencies": { - "@types/cors": "^2.8.19", - "@types/express": "^5.0.0", - "@types/node": "22.10.0", - "@types/react": "^19.2.2", - "@types/react-dom": "^19.2.2", - "@vitejs/plugin-react": "^4.3.4", - "concurrently": "^9.2.1", - "typescript": "^5.9.3", - "vite": "^6.0.0", - "vite-plugin-singlefile": "^2.3.0" - }, - "main": "dist/server.js", - "types": "dist/server.d.ts", - "exports": { - ".": { - "types": "./dist/server.d.ts", - "default": "./dist/server.js" - } - }, - "files": [ - "dist" - ], - "bin": { - "mcp-integration-server": "dist/index.js" - } -} diff --git a/examples/integration-server/server.ts b/examples/integration-server/server.ts deleted file mode 100644 index 346f4f7..0000000 --- a/examples/integration-server/server.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { - registerAppResource, - registerAppTool, - RESOURCE_MIME_TYPE, -} from "@modelcontextprotocol/ext-apps/server"; -import { - McpServer, - type CallToolResult, - type ReadResourceResult, -} from "@modelcontextprotocol/server"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { z } from "zod"; -// 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 RESOURCE_URI = "ui://get-time/mcp-app.html"; -const SAMPLE_DOWNLOAD_URI = "resource:///sample-report.txt"; - -/** - * Creates a new MCP server instance with tools and resources registered. - */ -export function createServer(): McpServer { - const server = new McpServer({ - name: "Integration Test Server", - version: "1.0.0", - }); - - registerAppTool( - server, - "get-time", - { - title: "Get Time", - description: "Returns the current server time.", - inputSchema: z.object({}), - outputSchema: z.object({ - time: z.string(), - }), - _meta: { ui: { resourceUri: RESOURCE_URI } }, - }, - async (): Promise => { - const time = new Date().toISOString(); - return { - content: [ - { - type: "text", - text: JSON.stringify({ time }), - }, - ], - structuredContent: { time }, - }; - }, - ); - - registerAppResource( - server, - RESOURCE_URI, - RESOURCE_URI, - { mimeType: RESOURCE_MIME_TYPE }, - async (): Promise => { - const html = await fs.readFile( - path.join(DIST_DIR, "mcp-app.html"), - "utf-8", - ); - return { - contents: [ - { uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }, - ], - }; - }, - ); - - // Sample downloadable resource โ€” used to demo ResourceLink in ui/download-file - server.registerResource( - SAMPLE_DOWNLOAD_URI, - SAMPLE_DOWNLOAD_URI, - { - mimeType: "text/plain", - }, - async (): Promise => { - const content = [ - "Integration Test Server โ€” Sample Report", - `Generated: ${new Date().toISOString()}`, - "", - "This file was downloaded via MCP ResourceLink.", - "The host resolved it by calling resources/read on the server.", - ].join("\n"); - return { - contents: [ - { uri: SAMPLE_DOWNLOAD_URI, mimeType: "text/plain", text: content }, - ], - }; - }, - ); - - return server; -} diff --git a/examples/integration-server/src/global.css b/examples/integration-server/src/global.css deleted file mode 100644 index 801291f..0000000 --- a/examples/integration-server/src/global.css +++ /dev/null @@ -1,92 +0,0 @@ -:root { - color-scheme: light dark; - - /* - * Fallbacks for host style variables used by this app. - * The host may provide these (and many more) via the host context. - */ - --color-text-primary: light-dark(#1f2937, #f3f4f6); - --color-text-inverse: light-dark(#f3f4f6, #1f2937); - --color-text-info: light-dark(#1d4ed8, #60a5fa); - --color-background-primary: light-dark(#ffffff, #1a1a1a); - --color-background-inverse: light-dark(#1a1a1a, #ffffff); - --color-background-info: light-dark(#eff6ff, #1e3a5f); - --color-ring-primary: light-dark(#3b82f6, #60a5fa); - --border-radius-md: 6px; - --border-width-regular: 1px; - --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; - --font-weight-normal: 400; - --font-weight-bold: 700; - --font-text-md-size: 1rem; - --font-text-md-line-height: 1.5; - --font-heading-3xl-size: 2.25rem; - --font-heading-3xl-line-height: 1.1; - --font-heading-2xl-size: 1.875rem; - --font-heading-2xl-line-height: 1.2; - --font-heading-xl-size: 1.5rem; - --font-heading-xl-line-height: 1.25; - --font-heading-lg-size: 1.25rem; - --font-heading-lg-line-height: 1.3; - --font-heading-md-size: 1rem; - --font-heading-md-line-height: 1.4; - --font-heading-sm-size: 0.875rem; - --font-heading-sm-line-height: 1.4; - - /* Spacing derived from host typography */ - --spacing-unit: var(--font-text-md-size); - --spacing-xs: calc(var(--spacing-unit) * 0.25); - --spacing-sm: calc(var(--spacing-unit) * 0.5); - --spacing-md: var(--spacing-unit); - --spacing-lg: calc(var(--spacing-unit) * 1.5); - - /* App accent color (customize for your brand) */ - --color-accent: #2563eb; - --color-text-on-accent: #ffffff; -} - -* { - box-sizing: border-box; -} - -html, body { - font-family: var(--font-sans); - font-size: var(--font-text-md-size); - font-weight: var(--font-weight-normal); - line-height: var(--font-text-md-line-height); - color: var(--color-text-primary); -} - -h1 { - font-size: var(--font-heading-3xl-size); - line-height: var(--font-heading-3xl-line-height); -} -h2 { - font-size: var(--font-heading-2xl-size); - line-height: var(--font-heading-2xl-line-height); -} -h3 { - font-size: var(--font-heading-xl-size); - line-height: var(--font-heading-xl-line-height); -} -h4 { - font-size: var(--font-heading-lg-size); - line-height: var(--font-heading-lg-line-height); -} -h5 { - font-size: var(--font-heading-md-size); - line-height: var(--font-heading-md-line-height); -} -h6 { - font-size: var(--font-heading-sm-size); - line-height: var(--font-heading-sm-line-height); -} - -code, pre, kbd { - font-family: var(--font-mono); - font-size: 1em; -} - -b, strong { - font-weight: var(--font-weight-bold); -} diff --git a/examples/integration-server/src/mcp-app.module.css b/examples/integration-server/src/mcp-app.module.css deleted file mode 100644 index ae08a8c..0000000 --- a/examples/integration-server/src/mcp-app.module.css +++ /dev/null @@ -1,79 +0,0 @@ -.main { - width: 100%; - max-width: 425px; - box-sizing: border-box; - - > * { - margin-top: 0; - margin-bottom: 0; - } - - > * + * { - margin-top: var(--spacing-lg); - } -} - -.action { - > * { - margin-top: 0; - margin-bottom: 0; - width: 100%; - } - - > * + * { - margin-top: var(--spacing-sm); - } - - /* Server time row: flex layout for consistent mask width in E2E tests */ - > p { - display: flex; - align-items: baseline; - gap: var(--spacing-xs); - } - - /* Consistent font for form inputs (inherits from global.css) */ - textarea, - input { - display: block; - font-family: inherit; - font-size: inherit; - } - - button { - padding: var(--spacing-sm) var(--spacing-md); - border: none; - border-radius: var(--border-radius-md); - color: var(--color-text-on-accent); - font-weight: var(--font-weight-bold); - background-color: var(--color-accent); - cursor: pointer; - - &:hover { - background-color: color-mix(in srgb, var(--color-accent) 85%, var(--color-background-inverse)); - } - - &:focus-visible { - outline: calc(var(--border-width-regular) * 2) solid var(--color-ring-primary); - outline-offset: var(--border-width-regular); - } - } -} - -.notice { - padding: var(--spacing-sm) var(--spacing-md); - color: var(--color-text-info); - text-align: center; - font-style: italic; - background-color: var(--color-background-info); - - &::before { - content: "โ„น๏ธ "; - font-style: normal; - } -} - -/* Server time fills remaining width for consistent E2E screenshot masking */ -.serverTime { - flex: 1; - min-width: 0; -} diff --git a/examples/integration-server/src/mcp-app.tsx b/examples/integration-server/src/mcp-app.tsx deleted file mode 100644 index 281a356..0000000 --- a/examples/integration-server/src/mcp-app.tsx +++ /dev/null @@ -1,242 +0,0 @@ -/** - * @file App that demonstrates a few features using MCP Apps SDK + React. - */ -import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps"; -import { useApp } from "@modelcontextprotocol/ext-apps/react"; -import type { CallToolResult } from "@modelcontextprotocol/client"; -import { StrictMode, useCallback, useEffect, useState } from "react"; -import { createRoot } from "react-dom/client"; -import styles from "./mcp-app.module.css"; - -const IMPLEMENTATION = { name: "Get Time App", version: "1.0.0" }; - -const log = { - info: console.log.bind(console, "[APP]"), - warn: console.warn.bind(console, "[APP]"), - error: console.error.bind(console, "[APP]"), -}; - -function extractTime(callToolResult: CallToolResult): string { - const { time } = callToolResult.structuredContent as { time: string }; - return time; -} - -function GetTimeApp() { - const [toolResult, setToolResult] = useState(null); - const [hostContext, setHostContext] = useState< - McpUiHostContext | undefined - >(); - const { app, error } = useApp({ - appInfo: IMPLEMENTATION, - capabilities: {}, - onAppCreated: (app) => { - app.onteardown = async () => { - log.info("App is being torn down"); - await new Promise((resolve) => setTimeout(resolve, 500)); // Simulate cleanup work - log.info("App teardown complete"); - return {}; - }; - app.ontoolinput = async (input) => { - log.info("Received tool call input:", input); - }; - - app.ontoolresult = async (result) => { - log.info("Received tool call result:", result); - setToolResult(result); - }; - - app.onerror = log.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 GetTimeAppInnerProps { - app: App; - toolResult: CallToolResult | null; - hostContext?: McpUiHostContext; -} -function GetTimeAppInner({ - app, - toolResult, - hostContext, -}: GetTimeAppInnerProps) { - const [serverTime, setServerTime] = useState("Loading..."); - const [messageText, setMessageText] = useState("This is message text."); - const [logText, setLogText] = useState("This is log text."); - const [linkUrl, setLinkUrl] = useState("https://modelcontextprotocol.io/"); - - useEffect(() => { - if (toolResult) { - setServerTime(extractTime(toolResult)); - } - }, [toolResult]); - - const handleGetTime = useCallback(async () => { - try { - log.info("Calling get-time tool..."); - const result = await app.callServerTool({ - name: "get-time", - arguments: {}, - }); - log.info("get-time result:", result); - setServerTime(extractTime(result)); - } catch (e) { - log.error(e); - setServerTime("[ERROR]"); - } - }, [app]); - - const handleSendMessage = useCallback(async () => { - const signal = AbortSignal.timeout(5000); - try { - log.info("Sending message text to Host:", messageText); - const { isError } = await app.sendMessage( - { role: "user", content: [{ type: "text", text: messageText }] }, - { signal }, - ); - log.info("Message", isError ? "rejected" : "accepted"); - } catch (e) { - log.error("Message send error:", signal.aborted ? "timed out" : e); - } - }, [app, messageText]); - - const handleSendLog = useCallback(async () => { - log.info("Sending log text to Host:", logText); - await app.sendLog({ level: "info", data: logText }); - }, [app, logText]); - - const handleOpenLink = useCallback(async () => { - log.info("Sending open link request to Host:", linkUrl); - const { isError } = await app.openLink({ url: linkUrl }); - log.info("Open link request", isError ? "rejected" : "accepted"); - }, [app, linkUrl]); - - const canDownload = app.getHostCapabilities()?.downloadFile !== undefined; - - const handleDownloadFile = useCallback(async () => { - const sampleContent = JSON.stringify( - { time: serverTime, exported: new Date().toISOString() }, - null, - 2, - ); - log.info("Requesting file download..."); - const { isError } = await app.downloadFile({ - contents: [ - { - type: "resource", - resource: { - uri: "file:///export.json", - mimeType: "application/json", - text: sampleContent, - }, - }, - ], - }); - log.info("Download", isError ? "rejected" : "accepted"); - }, [app, serverTime]); - - const handleDownloadLink = useCallback(async () => { - log.info("Requesting resource link download..."); - const { isError } = await app.downloadFile({ - contents: [ - { - type: "resource_link", - uri: "resource:///sample-report.txt", - name: "sample-report.txt", - mimeType: "text/plain", - }, - ], - }); - log.info("Resource link download", isError ? "rejected" : "accepted"); - }, [app]); - - return ( -
-

Watch activity in the DevTools console!

- -
-

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

- -
- -
-