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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6848e4c..a0b65a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,36 +10,6 @@ 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 @@ -60,7 +30,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - name: Verify no private URLs in package-lock.json shell: bash @@ -70,19 +40,14 @@ jobs: shell: bash run: node scripts/check-versions.mjs - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v4 with: - node-version: "22" - cache: npm + node-version: "20" - - run: npm ci + - run: npm install - 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 @@ -91,72 +56,35 @@ jobs: 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: 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 + node-version: "20" - - uses: astral-sh/setup-uv@v7 + - uses: astral-sh/setup-uv@v5 - run: npm ci - # 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: npx playwright install --with-deps chromium - name: Run E2E tests run: npx playwright test --reporter=list - name: Upload test results - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v4 if: failure() with: name: test-results @@ -169,9 +97,9 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - - uses: Vampire/setup-wsl@v6 + - uses: Vampire/setup-wsl@v5 with: distribution: Ubuntu-24.04 @@ -179,18 +107,13 @@ jobs: shell: wsl-bash {0} run: | sudo apt-get update - curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - + curl -fsSL https://deb.nodesource.com/setup_20.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 install npm run build npm run examples:build npm test @@ -217,20 +140,17 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v4 with: - node-version: "22" + node-version: "20" - 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}" + npm install "git+https://github.com/${{ github.event.pull_request.head.repo.full_name || github.repository }}#${{ github.head_ref || github.ref_name }}" # Verify the package is usable (ESM import) node --input-type=module -e "import { App } from '@modelcontextprotocol/ext-apps'; console.log('Import successful:', typeof App)" 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..0fbb170 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -12,13 +12,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 @@ -27,4 +26,3 @@ jobs: publish_dir: ./docs publish_branch: gh-pages keep_files: true - cname: apps.extensions.modelcontextprotocol.io diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index ed36c15..3b237cb 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,21 +31,17 @@ 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' @@ -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 @@ -92,7 +88,7 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'release' environment: Release - needs: [build, test] + needs: [publish] permissions: contents: read @@ -111,8 +107,6 @@ jobs: - budget-allocator-server - cohort-heatmap-server - customer-segmentation-server - - debug-server - - lazy-auth-server - map-server - pdf-server - scenario-modeler-server @@ -125,11 +119,11 @@ jobs: - wiki-explorer-server 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 @@ -139,45 +133,7 @@ jobs: - 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 }} + run: npm publish --workspace examples/${{ matrix.example }} --provenance --access public 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 }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 478c5b8..283577b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,8 +13,8 @@ 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 @@ -24,17 +24,11 @@ jobs: - 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 \ 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..1d78df5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,10 +11,3 @@ 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..7000397 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,12 +1,3 @@ -# 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" @@ -14,16 +5,8 @@ if grep -E '"resolved": "https?://' package-lock.json | grep -v registry.npmjs.o 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) +# Stage any changes to generated files (they may have been reformatted by prettier) 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..dd3c59b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,8 +2,5 @@ examples/basic-host/**/*.ts examples/basic-host/**/*.tsx examples/basic-server-*/**/*.ts examples/basic-server-*/**/*.tsx -examples/quickstart/**/*.ts **/vendor/** -**/.build/** -**/.claude/** SKILL.md diff --git a/AGENTS.md b/AGENTS.md index 219b947..520643d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,11 +6,11 @@ MCP Apps SDK (`@modelcontextprotocol/ext-apps`) enables MCP servers to display i Key abstractions: -- **View** - UI running in an iframe, uses `App` class with `PostMessageTransport` to communicate with host +- **Guest** - 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` +Specification (draft): `specification/draft/apps.mdx` ## Commands @@ -39,10 +39,7 @@ 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. +# Regenerate package-lock.json (especially on setups w/ custom npm registry) 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 && \ @@ -60,8 +57,8 @@ rm -fR package-lock.json node_modules && \ ### 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/app.ts` - `App` class extends MCP Protocol, handles guest initialization, tool calls, messaging +- `src/app-bridge.ts` - `AppBridge` class for hosts, proxies MCP requests, sends tool input/results to guests - `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 @@ -70,26 +67,20 @@ rm -fR package-lock.json node_modules && \ ### Protocol Flow ``` -View (App/Protocol) <--PostMessageTransport--> Host (AppBridge/Protocol) <--separate MCP Client--> MCP Server +Guest UI (App) <--PostMessageTransport--> Host (AppBridge) <--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 +1. Host creates iframe with Guest UI HTML +2. Guest UI creates `App` instance and calls `connect()` with `PostMessageTransport` +3. App sends `ui/initialize` request, receives host 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()` +5. Guest UI 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 +## Examples -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: +Uses npm workspaces. 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 @@ -97,7 +88,4 @@ Uses npm workspaces. Full examples in `examples/` are separate packages: ## 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 +The `plugins/mcp-apps/` directory contains a Claude Code plugin distributed via the plugin marketplace. It provides the "Create MCP App" skill (`plugins/mcp-apps/skills/create-mcp-app/SKILL.md`) that guides users through building MCP Apps with interactive UIs. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e28dcd1..81f776a 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,408 +40,6 @@ 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 @@ -490,19 +79,9 @@ 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 @@ -539,17 +118,40 @@ Before publishing releases, ensure the following are configured: ### 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 +175,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/README.md b/README.md index ab4004d..6057fe1 100644 --- a/README.md +++ b/README.md @@ -1,284 +1,96 @@ - -
- - - - 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 -

+[![npm version](https://img.shields.io/npm/v/@modelcontextprotocol/ext-apps.svg)](https://www.npmjs.com/package/@modelcontextprotocol/ext-apps) [![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 - -- [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) - -## Build with Agent Skills - -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: - -| 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"_ | - -### Install the Skills - -**Claude Code** โ€” install via the plugin marketplace: - -``` -/plugin marketplace add modelcontextprotocol/ext-apps -/plugin install mcp-apps@modelcontextprotocol-ext-apps -``` - -**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. - -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 -

- -> [!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. +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. ## How It Works -MCP Apps extend the Model Context Protocol by letting tools declare UI -resources: +MCP Apps extend the Model Context Protocol to let servers deliver **interactive UIs** to MCP hosts. Here's how it works: -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 +1. **Tool call** โ€” The LLM calls a tool on your server +2. **UI Resource** โ€” The tool's definition links to a predeclared `ui://` resource containing its HTML interface +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 +This enables dashboards, forms, visualizations, and other rich experiences inside chat interfaces. -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). +## Overview -For a View or host: +This SDK serves two audiences: + +### App Developers + +Build interactive UIs that run inside MCP-enabled chat clients. + +- **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) + +### Host Developers + +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 ```bash -npm install -S @modelcontextprotocol/ext-apps \ - @modelcontextprotocol/client@^2.0.0 \ - zod@^4.2.0 +npm install -S @modelcontextprotocol/ext-apps ``` -For an MCP server, add the server package (and, for HTTP transports, the Node -and Express adapters): +Or edit your `package.json` manually: -```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 +```json +{ + "dependencies": { + "@modelcontextprotocol/ext-apps": "^0.0.1" + } +} ``` -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. - ## Examples -The -[`examples/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples) -directory contains demo apps showcasing real-world use cases. - | | | | |:---:|:---:|:---:| -| [![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) | +| [![Map](examples/map-server/grid-cell.png "Interactive 3D globe viewer using CesiumJS")](examples/map-server) | [![Three.js](examples/threejs-server/grid-cell.png "Interactive 3D scene renderer")](examples/threejs-server) | [![ShaderToy](examples/shadertoy-server/grid-cell.png "Real-time GLSL shader renderer")](examples/shadertoy-server) | +| [**Map**](examples/map-server) | [**Three.js**](examples/threejs-server) | [**ShaderToy**](examples/shadertoy-server) | +| [![Sheet Music](examples/sheet-music-server/grid-cell.png "ABC notation to sheet music")](examples/sheet-music-server) | [![Wiki Explorer](examples/wiki-explorer-server/grid-cell.png "Wikipedia link graph visualization")](examples/wiki-explorer-server) | [![Cohort Heatmap](examples/cohort-heatmap-server/grid-cell.png "Customer retention heatmap")](examples/cohort-heatmap-server) | +| [**Sheet Music**](examples/sheet-music-server) | [**Wiki Explorer**](examples/wiki-explorer-server) | [**Cohort Heatmap**](examples/cohort-heatmap-server) | +| [![Scenario Modeler](examples/scenario-modeler-server/grid-cell.png "SaaS business projections")](examples/scenario-modeler-server) | [![Budget Allocator](examples/budget-allocator-server/grid-cell.png "Interactive budget allocation")](examples/budget-allocator-server) | [![Customer Segmentation](examples/customer-segmentation-server/grid-cell.png "Scatter chart with clustering")](examples/customer-segmentation-server) | +| [**Scenario Modeler**](examples/scenario-modeler-server) | [**Budget Allocator**](examples/budget-allocator-server) | [**Customer Segmentation**](examples/customer-segmentation-server) | +| [![System Monitor](examples/system-monitor-server/grid-cell.png "Real-time OS metrics")](examples/system-monitor-server) | [![Transcript](examples/transcript-server/grid-cell.png "Live speech transcription")](examples/transcript-server) | [![Video Resource](examples/video-resource-server/grid-cell.png "Binary video via MCP resources")](examples/video-resource-server) | +| [**System Monitor**](examples/system-monitor-server) | [**Transcript**](examples/transcript-server) | [**Video Resource**](examples/video-resource-server) | +| [![PDF Server](examples/pdf-server/grid-cell.png "Interactive PDF viewer with chunked loading")](examples/pdf-server) | [![QR Code](examples/qr-server/grid-cell.png "QR code generator")](examples/qr-server) | | +| [**PDF Server**](examples/pdf-server) | [**QR Code (Python)**](examples/qr-server) | | ### 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) | +| [![Basic](examples/basic-server-react/grid-cell.png "Starter template")](examples/basic-server-react) | The same app built with different frameworks โ€” pick your favorite!

[React](examples/basic-server-react) ยท [Vue](examples/basic-server-vue) ยท [Svelte](examples/basic-server-svelte) ยท [Preact](examples/basic-server-preact) ยท [Solid](examples/basic-server-solid) ยท [Vanilla JS](examples/basic-server-vanillajs) | -### Running the Examples +The [`examples/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples) directory contains additional demo apps showcasing real-world use cases. -#### 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): +To run all examples: ```bash -git clone https://github.com/modelcontextprotocol/ext-apps.git -cd ext-apps npm install npm 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) | - -
- ## 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/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..0e53418 100644 --- a/build.bun.ts +++ b/build.bun.ts @@ -1,18 +1,9 @@ #!/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 @@ -34,20 +25,10 @@ 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, + external: ["@modelcontextprotocol/sdk"], }), buildJs("src/app.ts", { outdir: "dist/src", @@ -55,19 +36,19 @@ await Promise.all([ }), buildJs("src/app-bridge.ts", { outdir: "dist/src", - external: PEER_EXTERNALS, + external: ["@modelcontextprotocol/sdk"], }), buildJs("src/react/index.tsx", { outdir: "dist/src/react", - external: ["react", "react-dom", ...PEER_EXTERNALS], + external: ["react", "react-dom", "@modelcontextprotocol/sdk"], }), buildJs("src/react/index.tsx", { outdir: "dist/src/react", - external: ["react", "react-dom"], + external: ["react", "react-dom", "@modelcontextprotocol/sdk"], naming: { entry: "react-with-deps.js" }, }), buildJs("src/server/index.ts", { outdir: "dist/src/server", - external: PEER_EXTERNALS, + external: ["@modelcontextprotocol/sdk"], }), ]); 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 index 22d5d9f..2845b8d 100644 --- a/docs/migrate_from_openai_apps.md +++ b/docs/migrate_from_openai_apps.md @@ -1,22 +1,9 @@ ---- -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. +This guide helps you migrate from the OpenAI Apps SDK to the MCP Apps SDK (`@modelcontextprotocol/ext-apps`). ## Server-Side -The server-side changes involve updating metadata structure and using helper functions. - ### Quick Start Comparison | OpenAI Apps SDK | MCP Apps SDK | @@ -37,13 +24,13 @@ The server-side changes involve updating metadata structure and using helper fun ### 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 | +| OpenAI | MCP Apps | Notes | +| ------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------- | +| `_meta["openai/widgetCSP"]` | `_meta.ui.csp` | `connect_domains` โ†’ `connectDomains`, `resource_domains` โ†’ `resourceDomains`, etc. | +| โ€” | `_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 @@ -51,22 +38,12 @@ The server-side changes involve updating metadata structure and using helper fun | --------------------- | --------------------------- | -------------------------------------------------------------------------------- | | `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) +### Before (OpenAI) ```typescript -import { McpServer } from "@modelcontextprotocol/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; function createServer() { @@ -78,10 +55,10 @@ function createServer() { { title: "Shopping Cart", description: "Display the user's shopping cart", - inputSchema: z.object({ userId: z.string() }), + inputSchema: { userId: z.string() }, annotations: { readOnlyHint: true }, _meta: { - "openai/outputTemplate": "ui://view/cart.html", + "openai/outputTemplate": "ui://widget/cart.html", "openai/toolInvocation/invoking": "Loading cart...", "openai/toolInvocation/invoked": "Cart ready", "openai/widgetAccessible": true, @@ -98,22 +75,15 @@ function createServer() { // Register UI resource server.registerResource( - "Cart View", - "ui://view/cart.html", + "Cart Widget", + "ui://widget/cart.html", { mimeType: "text/html+skybridge" }, async () => ({ contents: [ { - uri: "ui://view/cart.html", + uri: "ui://widget/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"], - }, - }, }, ], }), @@ -123,10 +93,10 @@ function createServer() { } ``` -#### After (MCP Apps) +### After (MCP Apps) ```typescript -import { McpServer } from "@modelcontextprotocol/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerAppTool, registerAppResource, @@ -144,9 +114,9 @@ function createServer() { { title: "Shopping Cart", description: "Display the user's shopping cart", - inputSchema: z.object({ userId: z.string() }), + inputSchema: { userId: z.string() }, annotations: { readOnlyHint: true }, - _meta: { ui: { resourceUri: "ui://view/cart.html" } }, + _meta: { ui: { resourceUri: "ui://widget/cart.html" } }, }, async (args) => { const cart = await getCart(args.userId); @@ -160,24 +130,15 @@ function createServer() { // Register UI resource registerAppResource( server, - "Cart View", - "ui://view/cart.html", + "Cart Widget", + "ui://widget/cart.html", { description: "Shopping cart UI" }, async () => ({ contents: [ { - uri: "ui://view/cart.html", + uri: "ui://widget/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"], - }, - }, - }, }, ], }), @@ -191,15 +152,13 @@ function createServer() { 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`) +3. **CSP Property Names**: snake_case โ†’ camelCase (`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. +## Client-side ### Quick Start Comparison @@ -213,7 +172,7 @@ Client-side migration involves replacing the implicit `window.openai` global wit | OpenAI | MCP Apps | Notes | | -------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| `window.openai` (auto-available) | `const app = new App({name, version})` | MCP requires explicit instantiation | +| `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 | @@ -293,17 +252,16 @@ Client-side migration involves replacing the implicit `window.openai` global wit | ------------------------------------------- | -------- | ------------------- | | `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 | +| OpenAI | MCP Apps | Notes | +| ------------------------------ | ------------------------------------------- | -------------------------------- | +| Read `window.openai.*` on load | `app.ontoolinput = (params) => {...}` | Register before `connect()` | +| Read `window.openai.*` on load | `app.ontoolresult = (params) => {...}` | Register before `connect()` | +| Poll or re-read properties | `app.onhostcontextchanged = (ctx) => {...}` | MCP pushes context changes | +| โ€” | `app.onteardown = async () => {...}` | MCP adds: cleanup before unmount | ### Logging @@ -318,7 +276,7 @@ Client-side migration involves replacing the implicit `window.openai` global wit | โ€” | `app.getHostVersion()` | Returns `{ name, version }` of host | | โ€” | `app.getHostCapabilities()` | Check `serverTools`, `openLinks`, `logging`, etc. | -### Client-Side Migration Example +### Full Migration Example #### Before (OpenAI) 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..9481df3 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,369 +1,203 @@ --- 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**. + +> [!NOTE] +> The complete example is available at [`examples/basic-server-vanillajs`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs). ## 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. +- Familiarity with MCP concepts, especially [Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools) and [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources) +- Familiarity with the [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) +- Node.js 18+ -We'll use the [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) to build the server. +> [!TIP] +> New to building MCP servers? Start with the [official MCP quickstart guide](https://modelcontextprotocol.io/docs/develop/build-server) to learn the core concepts first. -You'll also need Node.js 20+. +## 1. Project Setup -## 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 @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk +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. +> [!NOTE] +> **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" +```typescript +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { - registerAppResource, registerAppTool, + registerAppResource, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server"; -import { McpServer } from "@modelcontextprotocol/server"; +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, 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.", + inputSchema: {}, + _meta: { ui: { resourceUri } }, + }, + async () => { + 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 () => { + const html = await fs.readFile( + path.join(import.meta.dirname, "dist", "mcp-app.html"), + "utf-8", + ); + return { + contents: [ + { uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }, + ], + }; + }, +); + +// Start an Express server that exposes the MCP endpoint. +const expressApp = express(); +expressApp.use(cors()); +expressApp.use(express.json()); + +expressApp.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); +}); - // 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.", - 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 }] }; - }, - ); - - // 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)); - }; - - 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); +expressApp.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"); }); ``` -
+> [!NOTE] +> **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: - -``` -my-mcp-app/ -โ”œโ”€โ”€ main.ts -โ”œโ”€โ”€ package.json -โ”œโ”€โ”€ server.ts -โ”œโ”€โ”€ tsconfig.json -โ”œโ”€โ”€ tsconfig.server.json -โ””โ”€โ”€ vite.config.ts -``` - -Let's verify everything compiles: +Then, verify your server compiles: ```bash npx tsc --noEmit ``` -No output means success! If you see errors, check for typos in `server.ts` or `main.ts`. +No output means success. If you see errors, check for typos in `server.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 UI -## 3. Build the View +Create `mcp-app.html`: -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,10 +214,9 @@ 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" +```typescript import { App } from "@modelcontextprotocol/ext-apps"; // Get element references @@ -393,8 +226,7 @@ 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; serverTimeEl.textContent = time ?? "[ERROR]"; @@ -402,7 +234,6 @@ app.ontoolresult = (result) => { // 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; serverTimeEl.textContent = time ?? "[ERROR]"; @@ -412,76 +243,50 @@ getTimeBtn.addEventListener("click", async () => { app.connect(); ``` -Your `my-mcp-app` directory should now contain: +> [!NOTE] +> **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) -``` -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` which contains your bundled app: ```console -$ ls dist/ -mcp-app.html +$ ls dist/mcp-app.html +dist/mcp-app.html ``` -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/package.json b/examples/basic-host/package.json index cefe9d5..9975de4 100644 --- a/examples/basic-host/package.json +++ b/examples/basic-host/package.json @@ -1,7 +1,7 @@ { "homepage": "https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host", "name": "@modelcontextprotocol/ext-apps-basic-host", - "version": "2.0.0", + "version": "0.4.1", "type": "module", "scripts": { "build": "tsc --noEmit && concurrently \"cross-env INPUT=index.html vite build\" \"cross-env INPUT=sandbox.html vite build\"", @@ -11,15 +11,15 @@ "dev": "cross-env 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": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "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", @@ -29,6 +29,7 @@ "prettier": "^3.6.2", "typescript": "^5.9.3", "vite": "^6.0.0", - "vite-plugin-singlefile": "^2.3.0" + "vite-plugin-singlefile": "^2.3.0", + "vitest": "^3.2.4" } } diff --git a/examples/basic-host/sandbox.html b/examples/basic-host/sandbox.html index fb4cd8d..e0790fb 100644 --- a/examples/basic-host/sandbox.html +++ b/examples/basic-host/sandbox.html @@ -12,8 +12,6 @@ margin: 0; height: 100vh; width: 100vw; - /* Transparent background allows parent page to show through */ - background-color: transparent; } body { display: flex; @@ -28,8 +26,6 @@ padding: 0px; overflow: hidden; flex-grow: 1; - /* Inherit color scheme from parent for consistent transparency */ - color-scheme: inherit; } diff --git a/examples/basic-host/serve.ts b/examples/basic-host/serve.ts index bc3fbff..03c3a0b 100644 --- a/examples/basic-host/serve.ts +++ b/examples/basic-host/serve.ts @@ -82,8 +82,6 @@ function buildCspHeader(csp?: McpUiResourceCsp): string { `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 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..9ce9fa6 100644 --- a/examples/basic-host/src/implementation.ts +++ b/examples/basic-host/src/implementation.ts @@ -1,14 +1,7 @@ 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 { 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"; @@ -26,14 +19,16 @@ export interface ServerInfo { name: string; client: Client; tools: Map; - resources: Map; appHtmlCache: Map; } export async function connectToServer(serverUrl: URL): Promise { + const client = new Client(IMPLEMENTATION); + log.info("Connecting to server:", serverUrl.href); - const client = await connectWithFallback(serverUrl); + await client.connect(new StreamableHTTPClientTransport(serverUrl)); + log.info("Connection successful"); const name = client.getServerVersion()?.name ?? serverUrl.href; @@ -41,34 +36,7 @@ export async function connectToServer(serverUrl: URL): Promise { 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() }; -} - -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}`); - } + return { name, client, tools, appHtmlCache: new Map() }; } @@ -138,23 +106,14 @@ async function getUiResource(serverInfo: ServerInfo, uri: string): Promise { - 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. diff --git a/examples/basic-host/src/index.module.css b/examples/basic-host/src/index.module.css index 0bd09dd..75fa1de 100644 --- a/examples/basic-host/src/index.module.css +++ b/examples/basic-host/src/index.module.css @@ -1,34 +1,8 @@ -/* 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); - } -} - .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; @@ -54,11 +28,9 @@ select, textarea { padding: 0.5rem; - border: 1px solid var(--color-border); + border: 1px solid #ccc; border-radius: 4px; font-size: inherit; - background-color: var(--color-bg); - color: var(--color-text); } .toolSelect { @@ -71,7 +43,7 @@ resize: vertical; &[aria-invalid="true"] { - background-color: light-dark(#fee2e2, #7f1d1d); + background-color: #fdd; } } @@ -81,14 +53,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 { @@ -131,27 +103,27 @@ padding: 0; border: none; border-radius: 4px; - background: var(--color-border); + background: #e0e0e0; font-size: 1.25rem; line-height: 1; - color: var(--color-text-secondary); + color: #666; cursor: pointer; &:hover { - background: var(--color-bg-secondary); - color: var(--color-text); + background: #d0d0d0; + color: #333; } } } .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; } @@ -165,7 +137,7 @@ margin: 0; padding: 1rem; max-width: none; - background: var(--color-bg); + background: white; border: none; border-radius: 0; display: flex; @@ -201,30 +173,29 @@ width: 32px; height: 32px; padding: 0; - border: 1px solid var(--color-border); + border: 1px solid #ccc; border-radius: 4px; - background: var(--color-bg-secondary); - color: var(--color-text); + background: #f5f5f5; font-size: 1.25rem; cursor: pointer; &:hover { - background: var(--color-bg); + background: #e0e0e0; } } .collapsiblePanel { margin: 0.5rem 0; padding: 0.5rem 0.75rem; - border: 1px solid var(--color-border); + border: 1px solid #e0e0e0; border-radius: 4px; - background-color: var(--color-bg-secondary); + background-color: #fafafa; font-size: 0.875rem; cursor: pointer; transition: background-color 0.15s; &:hover { - background-color: var(--color-bg); + background-color: #f0f0f0; } } @@ -236,23 +207,23 @@ .collapsibleLabel { font-weight: 600; - color: var(--color-text); + color: #555; } .collapsibleSize { - color: var(--color-text-secondary); + color: #888; font-size: 0.75rem; } .collapsibleToggle { margin-left: auto; - color: var(--color-text-secondary); + color: #888; font-size: 0.75rem; } .collapsiblePreview { margin-top: 0.25rem; - color: var(--color-text-secondary); + color: #666; font-family: monospace; font-size: 0.8rem; white-space: nowrap; @@ -264,8 +235,7 @@ margin: 0.5rem 0 0; padding: 0.5rem; border-radius: 4px; - background-color: var(--color-bg); - color: var(--color-text); + background-color: #f5f5f5; font-family: monospace; font-size: 0.8rem; white-space: pre-wrap; @@ -276,6 +246,6 @@ .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..baaa7bb 100644 --- a/examples/basic-host/src/index.tsx +++ b/examples/basic-host/src/index.tsx @@ -1,9 +1,8 @@ import { getToolUiResourceUri, McpUiToolMetaSchema } from "@modelcontextprotocol/ext-apps/app-bridge"; -import type { Tool } from "@modelcontextprotocol/client"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; 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 styles from "./index.module.css"; /** @@ -55,39 +54,16 @@ interface HostProps { type ToolCallEntry = ToolCallInfo & { id: number }; let nextToolCallId = 0; -// Parse URL query params for debugging: ?server=name&tool=name&call=true&theme=hide +// Parse URL query params for debugging: ?server=name&tool=name&call=true 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) { const [toolCalls, setToolCalls] = useState([]); const [destroyingIds, setDestroyingIds] = useState>(new Set()); @@ -108,7 +84,6 @@ function Host({ serversPromise }: HostProps) { return ( <> - {!queryParams.hideThemeToggle && } {toolCalls.map((info) => ( { } }); -// Notify the Host that the Sandbox is ready to receive view HTML. +// Notify the Host that the Sandbox is ready to receive Guest UI HTML. // Use specific origin instead of "*" to ensure only the expected host receives this. window.parent.postMessage({ jsonrpc: "2.0", diff --git a/examples/basic-host/src/theme.ts b/examples/basic-host/src/theme.ts deleted file mode 100644 index 882b10a..0000000 --- a/examples/basic-host/src/theme.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Simple theme manager for the basic-host example. - * Manages light/dark theme state and notifies listeners. - */ - -export type Theme = "light" | "dark"; - -type ThemeListener = (theme: Theme) => void; - -const listeners = new Set(); - -// Get initial theme from system preference -let currentTheme: Theme = window.matchMedia("(prefers-color-scheme: dark)").matches - ? "dark" - : "light"; - -// Apply theme to document -function applyTheme(theme: Theme) { - document.documentElement.setAttribute("data-theme", theme); - document.documentElement.style.colorScheme = theme; -} - -// Initial application -applyTheme(currentTheme); - -/** - * Get current theme. - */ -export function getTheme(): Theme { - return currentTheme; -} - -/** - * Set theme and notify all listeners. - */ -export function setTheme(theme: Theme): void { - if (theme === currentTheme) return; - currentTheme = theme; - applyTheme(theme); - listeners.forEach((listener) => listener(theme)); -} - -/** - * Toggle between light and dark themes. - */ -export function toggleTheme(): Theme { - const newTheme = currentTheme === "dark" ? "light" : "dark"; - setTheme(newTheme); - return newTheme; -} - -/** - * Subscribe to theme changes. - * Returns unsubscribe function. - */ -export function onThemeChange(listener: ThemeListener): () => void { - listeners.add(listener); - return () => listeners.delete(listener); -} - -// Also listen for system preference changes -window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => { - setTheme(e.matches ? "dark" : "light"); -}); diff --git a/examples/basic-host/tsconfig.json b/examples/basic-host/tsconfig.json index 3c143ca..9d7c3bc 100644 --- a/examples/basic-host/tsconfig.json +++ b/examples/basic-host/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-preact/README.md b/examples/basic-server-preact/README.md index 6a75ddb..8091ad8 100644 --- a/examples/basic-server-preact/README.md +++ b/examples/basic-server-preact/README.md @@ -5,50 +5,11 @@ An MCP App example with a Preact UI. > [!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-preact": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-basic-preact", - "--stdio" - ] - } - } -} -``` - -### Local Development - -To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path): - -```json -{ - "mcpServers": { - "basic-preact": { - "command": "bash", - "args": [ - "-c", - "cd ~/code/ext-apps/examples/basic-server-preact && npm run build >&2 && node dist/index.js --stdio" - ] - } - } -} -``` - ## Overview - Tool registration with a linked UI resource -- Preact 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) +- Preact UI using the [`App`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html) class +- 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), [`openLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#openlink) ## Key Files @@ -70,4 +31,4 @@ npm run dev ## 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. +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://modelcontextprotocol.github.io/ext-apps/api/interfaces/app.McpUiResourceCsp.html#resourcedomains) in the UI resource metadata. diff --git a/examples/basic-server-preact/main.ts b/examples/basic-server-preact/main.ts index ffdff52..d9a51a2 100644 --- a/examples/basic-server-preact/main.ts +++ b/examples/basic-server-preact/main.ts @@ -4,30 +4,41 @@ * 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"; +/** + * Shared utilities for running MCP servers with Streamable HTTP transport. + */ + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; import { createServer } from "./server.js"; +export interface ServerOptions { + port: number; + name?: string; +} + /** * Starts an MCP server with Streamable HTTP transport in stateless mode. * * @param createServer - Factory function that creates a new McpServer instance per request. + * @param options - Server configuration options. */ -export async function startStreamableHTTPServer( +export async function startServer( createServer: () => McpServer, + options: ServerOptions, ): Promise { - const port = parseInt(process.env.PORT ?? "3001", 10); + const { port, name = "MCP Server" } = options; 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({ + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); @@ -56,7 +67,7 @@ export async function startStreamableHTTPServer( console.error("Failed to start server:", err); process.exit(1); } - console.log(`MCP server listening on http://localhost:${port}/mcp`); + console.log(`${name} listening on http://localhost:${port}/mcp`); }); const shutdown = () => { @@ -68,22 +79,12 @@ export async function startStreamableHTTPServer( 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); + await createServer().connect(new StdioServerTransport()); } else { - await startStreamableHTTPServer(createServer); + const port = parseInt(process.env.PORT ?? "3001", 10); + await startServer(createServer, { port, name: "Basic MCP App Server (Preact)" }); } } diff --git a/examples/basic-server-preact/package.json b/examples/basic-server-preact/package.json index 60aa40d..bbd31c7 100644 --- a/examples/basic-server-preact/package.json +++ b/examples/basic-server-preact/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-basic-preact", - "version": "2.0.0", + "version": "0.4.1", "type": "module", "description": "Basic MCP App Server example using Preact", "repository": { "type": "git", - "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", + "url": "https://github.com/modelcontextprotocol/ext-apps", "directory": "examples/basic-server-preact" }, "license": "MIT", @@ -17,28 +17,23 @@ "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\"", + "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", + "@modelcontextprotocol/ext-apps": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", "preact": "^10.0.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "devDependencies": { "@preact/preset-vite": "^2.0.0", "@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", "typescript": "^5.9.3", diff --git a/examples/basic-server-preact/server.ts b/examples/basic-server-preact/server.ts index dfec94a..2d99964 100644 --- a/examples/basic-server-preact/server.ts +++ b/examples/basic-server-preact/server.ts @@ -1,12 +1,8 @@ -import { - McpServer, - type CallToolResult, - type ReadResourceResult, -} from "@modelcontextprotocol/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; import fs from "node:fs/promises"; import path from "node:path"; 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") @@ -32,8 +28,8 @@ export function createServer(): McpServer { { 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 + inputSchema: {}, + _meta: { ui: { resourceUri } }, }, async (): Promise => { const time = new Date().toISOString(); diff --git a/examples/basic-server-preact/src/global.css b/examples/basic-server-preact/src/global.css index 801291f..97cda44 100644 --- a/examples/basic-server-preact/src/global.css +++ b/examples/basic-server-preact/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-preact/src/mcp-app.module.css b/examples/basic-server-preact/src/mcp-app.module.css index ae08a8c..0c6429a 100644 --- a/examples/basic-server-preact/src/mcp-app.module.css +++ b/examples/basic-server-preact/src/mcp-app.module.css @@ -1,4 +1,8 @@ .main { + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + --color-notice-bg: #eff6ff; + width: 100%; max-width: 425px; box-sizing: border-box; @@ -9,7 +13,7 @@ } > * + * { - margin-top: var(--spacing-lg); + margin-top: 1.5rem; } } @@ -21,59 +25,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 */ -.serverTime { - flex: 1; - min-width: 0; -} diff --git a/examples/basic-server-preact/src/mcp-app.tsx b/examples/basic-server-preact/src/mcp-app.tsx index 351a8ec..d5b6b60 100644 --- a/examples/basic-server-preact/src/mcp-app.tsx +++ b/examples/basic-server-preact/src/mcp-app.tsx @@ -8,7 +8,7 @@ import { applyHostStyleVariables, type McpUiHostContext, } from "@modelcontextprotocol/ext-apps"; -import type { CallToolResult } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { useCallback, useEffect, useState } from "preact/hooks"; import { render } from "preact"; import styles from "./mcp-app.module.css"; @@ -144,7 +144,7 @@ function GetTimeAppInner({ app, toolResult, hostContext }: GetTimeAppInnerProps)

- Server Time: {serverTime} + Server Time: {serverTime}

diff --git a/examples/basic-server-preact/tsconfig.json b/examples/basic-server-preact/tsconfig.json index 9bf9389..32182d9 100644 --- a/examples/basic-server-preact/tsconfig.json +++ b/examples/basic-server-preact/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-preact/tsconfig.server.json b/examples/basic-server-preact/tsconfig.server.json index 27eb28f..05ddd8e 100644 --- a/examples/basic-server-preact/tsconfig.server.json +++ b/examples/basic-server-preact/tsconfig.server.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/basic-server-react/README.md b/examples/basic-server-react/README.md index d2f2e0d..d29073d 100644 --- a/examples/basic-server-react/README.md +++ b/examples/basic-server-react/README.md @@ -7,50 +7,11 @@ An MCP App example with a React UI. > [!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-react": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-basic-react", - "--stdio" - ] - } - } -} -``` - -### Local Development - -To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path): - -```json -{ - "mcpServers": { - "basic-react": { - "command": "bash", - "args": [ - "-c", - "cd ~/code/ext-apps/examples/basic-server-react && npm run build >&2 && node dist/index.js --stdio" - ] - } - } -} -``` - ## Overview - 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) +- React UI using the [`useApp()`](https://modelcontextprotocol.github.io/ext-apps/api/functions/_modelcontextprotocol_ext-apps_react.useApp.html) hook +- 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), [`openLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#openlink) ## Key Files @@ -72,4 +33,4 @@ npm run dev ## 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. +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://modelcontextprotocol.github.io/ext-apps/api/interfaces/app.McpUiResourceCsp.html#resourcedomains) in the UI resource metadata. diff --git a/examples/basic-server-react/main.ts b/examples/basic-server-react/main.ts index 21c5e18..39840a1 100644 --- a/examples/basic-server-react/main.ts +++ b/examples/basic-server-react/main.ts @@ -4,30 +4,37 @@ * 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 { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; import { createServer } from "./server.js"; +export interface ServerOptions { + port: number; + name?: string; +} + /** * Starts an MCP server with Streamable HTTP transport in stateless mode. * * @param createServer - Factory function that creates a new McpServer instance per request. + * @param options - Server configuration options. */ -export async function startStreamableHTTPServer( +export async function startServer( createServer: () => McpServer, + options: ServerOptions, ): Promise { - const port = parseInt(process.env.PORT ?? "3001", 10); + const { port, name = "MCP Server" } = options; 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({ + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); @@ -56,7 +63,7 @@ export async function startStreamableHTTPServer( console.error("Failed to start server:", err); process.exit(1); } - console.log(`MCP server listening on http://localhost:${port}/mcp`); + console.log(`${name} listening on http://localhost:${port}/mcp`); }); const shutdown = () => { @@ -68,22 +75,12 @@ export async function startStreamableHTTPServer( 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); + await createServer().connect(new StdioServerTransport()); } else { - await startStreamableHTTPServer(createServer); + const port = parseInt(process.env.PORT ?? "3001", 10); + await startServer(createServer, { port, name: "Basic MCP App Server (React)" }); } } diff --git a/examples/basic-server-react/package.json b/examples/basic-server-react/package.json index ea51a8a..18ba617 100644 --- a/examples/basic-server-react/package.json +++ b/examples/basic-server-react/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-basic-react", - "version": "2.0.0", + "version": "0.4.1", "type": "module", "description": "Basic MCP App Server example using React", "repository": { "type": "git", - "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", + "url": "https://github.com/modelcontextprotocol/ext-apps", "directory": "examples/basic-server-react" }, "license": "MIT", @@ -27,28 +27,23 @@ "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\"", + "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", + "@modelcontextprotocol/ext-apps": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "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", diff --git a/examples/basic-server-react/server.ts b/examples/basic-server-react/server.ts index d77c908..416b217 100644 --- a/examples/basic-server-react/server.ts +++ b/examples/basic-server-react/server.ts @@ -1,12 +1,8 @@ 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 type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; 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") @@ -33,8 +29,8 @@ export function createServer(): McpServer { { 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 + inputSchema: {}, + _meta: { ui: { resourceUri } }, }, async (): Promise => { const time = new Date().toISOString(); diff --git a/examples/basic-server-react/src/global.css b/examples/basic-server-react/src/global.css index 801291f..97cda44 100644 --- a/examples/basic-server-react/src/global.css +++ b/examples/basic-server-react/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-react/src/mcp-app.module.css b/examples/basic-server-react/src/mcp-app.module.css index ae08a8c..0c6429a 100644 --- a/examples/basic-server-react/src/mcp-app.module.css +++ b/examples/basic-server-react/src/mcp-app.module.css @@ -1,4 +1,8 @@ .main { + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + --color-notice-bg: #eff6ff; + width: 100%; max-width: 425px; box-sizing: border-box; @@ -9,7 +13,7 @@ } > * + * { - margin-top: var(--spacing-lg); + margin-top: 1.5rem; } } @@ -21,59 +25,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 */ -.serverTime { - flex: 1; - min-width: 0; -} diff --git a/examples/basic-server-react/src/mcp-app.tsx b/examples/basic-server-react/src/mcp-app.tsx index 2b5fbef..a42fce7 100644 --- a/examples/basic-server-react/src/mcp-app.tsx +++ b/examples/basic-server-react/src/mcp-app.tsx @@ -3,7 +3,7 @@ */ import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps"; import { useApp } from "@modelcontextprotocol/ext-apps/react"; -import type { CallToolResult } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { StrictMode, useCallback, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import styles from "./mcp-app.module.css"; @@ -131,7 +131,7 @@ function GetTimeAppInner({ app, toolResult, hostContext }: GetTimeAppInnerProps)

- Server Time: {serverTime} + Server Time: {serverTime}

diff --git a/examples/basic-server-react/tsconfig.json b/examples/basic-server-react/tsconfig.json index 3ecbdad..fc3c210 100644 --- a/examples/basic-server-react/tsconfig.json +++ b/examples/basic-server-react/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-react/tsconfig.server.json b/examples/basic-server-react/tsconfig.server.json index 27eb28f..05ddd8e 100644 --- a/examples/basic-server-react/tsconfig.server.json +++ b/examples/basic-server-react/tsconfig.server.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/basic-server-solid/README.md b/examples/basic-server-solid/README.md index 43811a2..e0e7584 100644 --- a/examples/basic-server-solid/README.md +++ b/examples/basic-server-solid/README.md @@ -5,50 +5,11 @@ An MCP App example with a Solid UI. > [!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-solid": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-basic-solid", - "--stdio" - ] - } - } -} -``` - -### Local Development - -To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path): - -```json -{ - "mcpServers": { - "basic-solid": { - "command": "bash", - "args": [ - "-c", - "cd ~/code/ext-apps/examples/basic-server-solid && npm run build >&2 && node dist/index.js --stdio" - ] - } - } -} -``` - ## Overview - Tool registration with a linked UI resource -- Solid 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) +- Solid UI using the [`App`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html) class +- 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), [`openLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#openlink) ## Key Files @@ -70,4 +31,4 @@ npm run dev ## 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. +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://modelcontextprotocol.github.io/ext-apps/api/interfaces/app.McpUiResourceCsp.html#resourcedomains) in the UI resource metadata. diff --git a/examples/basic-server-solid/main.ts b/examples/basic-server-solid/main.ts index 095ef8d..2335348 100644 --- a/examples/basic-server-solid/main.ts +++ b/examples/basic-server-solid/main.ts @@ -4,30 +4,41 @@ * 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"; +/** + * Shared utilities for running MCP servers with Streamable HTTP transport. + */ + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; import { createServer } from "./server.js"; +export interface ServerOptions { + port: number; + name?: string; +} + /** * Starts an MCP server with Streamable HTTP transport in stateless mode. * * @param createServer - Factory function that creates a new McpServer instance per request. + * @param options - Server configuration options. */ -export async function startStreamableHTTPServer( +export async function startServer( createServer: () => McpServer, + options: ServerOptions, ): Promise { - const port = parseInt(process.env.PORT ?? "3001", 10); + const { port, name = "MCP Server" } = options; 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({ + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); @@ -56,7 +67,7 @@ export async function startStreamableHTTPServer( console.error("Failed to start server:", err); process.exit(1); } - console.log(`MCP server listening on http://localhost:${port}/mcp`); + console.log(`${name} listening on http://localhost:${port}/mcp`); }); const shutdown = () => { @@ -68,22 +79,12 @@ export async function startStreamableHTTPServer( 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); + await createServer().connect(new StdioServerTransport()); } else { - await startStreamableHTTPServer(createServer); + const port = parseInt(process.env.PORT ?? "3001", 10); + await startServer(createServer, { port, name: "Basic MCP App Server (Solid)" }); } } diff --git a/examples/basic-server-solid/package.json b/examples/basic-server-solid/package.json index b3ca909..25331c5 100644 --- a/examples/basic-server-solid/package.json +++ b/examples/basic-server-solid/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-basic-solid", - "version": "2.0.0", + "version": "0.4.1", "type": "module", "description": "Basic MCP App Server example using Solid", "repository": { "type": "git", - "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", + "url": "https://github.com/modelcontextprotocol/ext-apps", "directory": "examples/basic-server-solid" }, "license": "MIT", @@ -17,33 +17,28 @@ "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\"", + "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", + "@modelcontextprotocol/ext-apps": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", - "solid-js": "1.9.10", - "zod": "^4.2.0" + "solid-js": "^1.9.0", + "zod": "^4.1.13" }, "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", "typescript": "^5.9.3", "vite": "^6.0.0", "vite-plugin-singlefile": "^2.3.0", - "vite-plugin-solid": "^2.11.12" + "vite-plugin-solid": "^2.0.0" }, "types": "dist/server.d.ts", "exports": { diff --git a/examples/basic-server-solid/server.ts b/examples/basic-server-solid/server.ts index addb522..33f76a1 100644 --- a/examples/basic-server-solid/server.ts +++ b/examples/basic-server-solid/server.ts @@ -1,12 +1,8 @@ -import { - McpServer, - type CallToolResult, - type ReadResourceResult, -} from "@modelcontextprotocol/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; import fs from "node:fs/promises"; import path from "node:path"; 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") @@ -32,8 +28,8 @@ export function createServer(): McpServer { { 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 + inputSchema: {}, + _meta: { ui: { resourceUri } }, }, async (): Promise => { const time = new Date().toISOString(); diff --git a/examples/basic-server-solid/src/global.css b/examples/basic-server-solid/src/global.css index 801291f..97cda44 100644 --- a/examples/basic-server-solid/src/global.css +++ b/examples/basic-server-solid/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-solid/src/mcp-app.module.css b/examples/basic-server-solid/src/mcp-app.module.css index ae08a8c..0c6429a 100644 --- a/examples/basic-server-solid/src/mcp-app.module.css +++ b/examples/basic-server-solid/src/mcp-app.module.css @@ -1,4 +1,8 @@ .main { + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + --color-notice-bg: #eff6ff; + width: 100%; max-width: 425px; box-sizing: border-box; @@ -9,7 +13,7 @@ } > * + * { - margin-top: var(--spacing-lg); + margin-top: 1.5rem; } } @@ -21,59 +25,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 */ -.serverTime { - flex: 1; - min-width: 0; -} diff --git a/examples/basic-server-solid/src/mcp-app.tsx b/examples/basic-server-solid/src/mcp-app.tsx index 2d78b7b..fa054e6 100644 --- a/examples/basic-server-solid/src/mcp-app.tsx +++ b/examples/basic-server-solid/src/mcp-app.tsx @@ -8,7 +8,7 @@ import { applyHostStyleVariables, type McpUiHostContext, } from "@modelcontextprotocol/ext-apps"; -import type { CallToolResult } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { createEffect, createSignal, onMount, Show } from "solid-js"; import { render } from "solid-js/web"; import styles from "./mcp-app.module.css"; @@ -149,7 +149,7 @@ function GetTimeAppInner(props: GetTimeAppInnerProps) {

- Server Time: {serverTime()} + Server Time: {serverTime()}

diff --git a/examples/basic-server-solid/tsconfig.json b/examples/basic-server-solid/tsconfig.json index bb13a01..84f0efe 100644 --- a/examples/basic-server-solid/tsconfig.json +++ b/examples/basic-server-solid/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-solid/tsconfig.server.json b/examples/basic-server-solid/tsconfig.server.json index 27eb28f..05ddd8e 100644 --- a/examples/basic-server-solid/tsconfig.server.json +++ b/examples/basic-server-solid/tsconfig.server.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/basic-server-svelte/README.md b/examples/basic-server-svelte/README.md index ec6a0d2..22505c8 100644 --- a/examples/basic-server-svelte/README.md +++ b/examples/basic-server-svelte/README.md @@ -5,50 +5,11 @@ An MCP App example with a Svelte 5 UI using runes for reactivity. > [!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-svelte": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-basic-svelte", - "--stdio" - ] - } - } -} -``` - -### Local Development - -To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path): - -```json -{ - "mcpServers": { - "basic-svelte": { - "command": "bash", - "args": [ - "-c", - "cd ~/code/ext-apps/examples/basic-server-svelte && npm run build >&2 && node dist/index.js --stdio" - ] - } - } -} -``` - ## Overview - Tool registration with a linked UI resource -- Svelte 5 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) +- Svelte 5 UI using the [`App`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html) class +- 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), [`openLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#openlink) ## Key Files @@ -70,4 +31,4 @@ npm run dev ## 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. +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://modelcontextprotocol.github.io/ext-apps/api/interfaces/app.McpUiResourceCsp.html#resourcedomains) in the UI resource metadata. diff --git a/examples/basic-server-svelte/main.ts b/examples/basic-server-svelte/main.ts index 734ec0c..762a40e 100644 --- a/examples/basic-server-svelte/main.ts +++ b/examples/basic-server-svelte/main.ts @@ -4,30 +4,41 @@ * 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"; +/** + * Shared utilities for running MCP servers with Streamable HTTP transport. + */ + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; import { createServer } from "./server.js"; +export interface ServerOptions { + port: number; + name?: string; +} + /** * Starts an MCP server with Streamable HTTP transport in stateless mode. * * @param createServer - Factory function that creates a new McpServer instance per request. + * @param options - Server configuration options. */ -export async function startStreamableHTTPServer( +export async function startServer( createServer: () => McpServer, + options: ServerOptions, ): Promise { - const port = parseInt(process.env.PORT ?? "3001", 10); + const { port, name = "MCP Server" } = options; 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({ + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); @@ -56,7 +67,7 @@ export async function startStreamableHTTPServer( console.error("Failed to start server:", err); process.exit(1); } - console.log(`MCP server listening on http://localhost:${port}/mcp`); + console.log(`${name} listening on http://localhost:${port}/mcp`); }); const shutdown = () => { @@ -68,22 +79,12 @@ export async function startStreamableHTTPServer( 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); + await createServer().connect(new StdioServerTransport()); } else { - await startStreamableHTTPServer(createServer); + const port = parseInt(process.env.PORT ?? "3001", 10); + await startServer(createServer, { port, name: "Basic MCP App Server (Svelte)" }); } } diff --git a/examples/basic-server-svelte/package.json b/examples/basic-server-svelte/package.json index 19fe4c9..4bfb9cb 100644 --- a/examples/basic-server-svelte/package.json +++ b/examples/basic-server-svelte/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-basic-svelte", - "version": "2.0.0", + "version": "0.4.1", "type": "module", "description": "Basic MCP App Server example using Svelte", "repository": { "type": "git", - "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", + "url": "https://github.com/modelcontextprotocol/ext-apps", "directory": "examples/basic-server-svelte" }, "license": "MIT", @@ -17,28 +17,23 @@ "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\"", + "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", + "@modelcontextprotocol/ext-apps": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", "svelte": "^5.0.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "@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", "typescript": "^5.9.3", diff --git a/examples/basic-server-svelte/server.ts b/examples/basic-server-svelte/server.ts index 9414285..dd2dba6 100644 --- a/examples/basic-server-svelte/server.ts +++ b/examples/basic-server-svelte/server.ts @@ -1,12 +1,8 @@ -import { - McpServer, - type CallToolResult, - type ReadResourceResult, -} from "@modelcontextprotocol/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; import fs from "node:fs/promises"; import path from "node:path"; 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") @@ -32,8 +28,8 @@ export function createServer(): McpServer { { 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 + inputSchema: {}, + _meta: { ui: { resourceUri } }, }, async (): Promise => { const time = new Date().toISOString(); diff --git a/examples/basic-server-svelte/src/App.svelte b/examples/basic-server-svelte/src/App.svelte index cabcc31..31fe972 100644 --- a/examples/basic-server-svelte/src/App.svelte +++ b/examples/basic-server-svelte/src/App.svelte @@ -7,7 +7,7 @@ import { applyHostStyleVariables, type McpUiHostContext, } from "@modelcontextprotocol/ext-apps"; -import type { CallToolResult } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; function extractTime(result: CallToolResult): string { const { text } = result.content?.find((c) => c.type === "text")!; @@ -111,7 +111,7 @@ async function handleOpenLink() {

Watch activity in the DevTools console!

-

Server Time: {serverTime}

+

Server Time: {serverTime}

@@ -133,6 +133,10 @@ async function handleOpenLink() { diff --git a/examples/basic-server-svelte/src/global.css b/examples/basic-server-svelte/src/global.css index 801291f..97cda44 100644 --- a/examples/basic-server-svelte/src/global.css +++ b/examples/basic-server-svelte/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-svelte/tsconfig.json b/examples/basic-server-svelte/tsconfig.json index 4fd3cd7..5edb66a 100644 --- a/examples/basic-server-svelte/tsconfig.json +++ b/examples/basic-server-svelte/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-svelte/tsconfig.server.json b/examples/basic-server-svelte/tsconfig.server.json index 27eb28f..05ddd8e 100644 --- a/examples/basic-server-svelte/tsconfig.server.json +++ b/examples/basic-server-svelte/tsconfig.server.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/basic-server-vanillajs/README.md b/examples/basic-server-vanillajs/README.md index 6445d8c..41e23e3 100644 --- a/examples/basic-server-vanillajs/README.md +++ b/examples/basic-server-vanillajs/README.md @@ -5,51 +5,12 @@ 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), [`openLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#openlink) +- Theme integration via [`applyDocumentTheme()`](https://modelcontextprotocol.github.io/ext-apps/api/functions/app.applyDocumentTheme.html), [`applyHostStyleVariables()`](https://modelcontextprotocol.github.io/ext-apps/api/functions/app.applyHostStyleVariables.html), and [`applyHostFonts()`](https://modelcontextprotocol.github.io/ext-apps/api/functions/app.applyHostFonts.html) ## Key Files @@ -71,4 +32,4 @@ npm run dev ## 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. +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://modelcontextprotocol.github.io/ext-apps/api/interfaces/app.McpUiResourceCsp.html#resourcedomains) in the UI resource metadata. diff --git a/examples/basic-server-vanillajs/main.ts b/examples/basic-server-vanillajs/main.ts index 4de7331..d53d53b 100644 --- a/examples/basic-server-vanillajs/main.ts +++ b/examples/basic-server-vanillajs/main.ts @@ -4,30 +4,41 @@ * 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"; +/** + * Shared utilities for running MCP servers with Streamable HTTP transport. + */ + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; import { createServer } from "./server.js"; +export interface ServerOptions { + port: number; + name?: string; +} + /** * Starts an MCP server with Streamable HTTP transport in stateless mode. * * @param createServer - Factory function that creates a new McpServer instance per request. + * @param options - Server configuration options. */ -export async function startStreamableHTTPServer( +export async function startServer( createServer: () => McpServer, + options: ServerOptions, ): Promise { - const port = parseInt(process.env.PORT ?? "3001", 10); + const { port, name = "MCP Server" } = options; 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({ + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); @@ -56,7 +67,7 @@ export async function startStreamableHTTPServer( console.error("Failed to start server:", err); process.exit(1); } - console.log(`MCP server listening on http://localhost:${port}/mcp`); + console.log(`${name} listening on http://localhost:${port}/mcp`); }); const shutdown = () => { @@ -68,22 +79,12 @@ export async function startStreamableHTTPServer( 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); + await createServer().connect(new StdioServerTransport()); } else { - await startStreamableHTTPServer(createServer); + const port = parseInt(process.env.PORT ?? "3102", 10); + await startServer(createServer, { port, name: "Basic MCP App Server (Vanilla JS)" }); } } diff --git a/examples/basic-server-vanillajs/package.json b/examples/basic-server-vanillajs/package.json index ebfebbb..35047e5 100644 --- a/examples/basic-server-vanillajs/package.json +++ b/examples/basic-server-vanillajs/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-basic-vanillajs", - "version": "2.0.0", + "version": "0.4.1", "type": "module", "description": "Basic MCP App Server example using vanilla JavaScript", "repository": { "type": "git", - "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", + "url": "https://github.com/modelcontextprotocol/ext-apps", "directory": "examples/basic-server-vanillajs" }, "license": "MIT", @@ -17,26 +17,21 @@ "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\"", + "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", + "@modelcontextprotocol/ext-apps": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "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", "typescript": "^5.9.3", diff --git a/examples/basic-server-vanillajs/server.ts b/examples/basic-server-vanillajs/server.ts index 693d861..07e82c2 100644 --- a/examples/basic-server-vanillajs/server.ts +++ b/examples/basic-server-vanillajs/server.ts @@ -1,9 +1,6 @@ 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 type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; @@ -32,11 +29,11 @@ export function createServer(): McpServer { { title: "Get Time", description: "Returns the current server time as an ISO 8601 string.", - inputSchema: z.object({}), + inputSchema: {}, outputSchema: z.object({ time: z.string(), }), - _meta: { ui: { resourceUri } }, // Links this tool to its UI resource + _meta: { ui: { resourceUri } }, }, async (): Promise => { const time = new Date().toISOString(); 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..0c6429a 100644 --- a/examples/basic-server-vanillajs/src/mcp-app.css +++ b/examples/basic-server-vanillajs/src/mcp-app.css @@ -1,4 +1,8 @@ .main { + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + --color-notice-bg: #eff6ff; + width: 100%; max-width: 425px; box-sizing: border-box; @@ -9,7 +13,7 @@ } > * + * { - margin-top: var(--spacing-lg); + margin-top: 1.5rem; } } @@ -21,59 +25,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..c3168c2 100644 --- a/examples/basic-server-vanillajs/src/mcp-app.ts +++ b/examples/basic-server-vanillajs/src/mcp-app.ts @@ -8,7 +8,7 @@ import { applyHostStyleVariables, type McpUiHostContext, } from "@modelcontextprotocol/ext-apps"; -import type { CallToolResult } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import "./global.css"; import "./mcp-app.css"; 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 index 27eb28f..05ddd8e 100644 --- a/examples/basic-server-vanillajs/tsconfig.server.json +++ b/examples/basic-server-vanillajs/tsconfig.server.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/basic-server-vue/README.md b/examples/basic-server-vue/README.md index c484f5b..2090973 100644 --- a/examples/basic-server-vue/README.md +++ b/examples/basic-server-vue/README.md @@ -5,50 +5,11 @@ 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) +- Vue 3 UI using the [`App`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html) class +- 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), [`openLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#openlink) ## Key Files @@ -70,4 +31,4 @@ npm run dev ## 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. +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://modelcontextprotocol.github.io/ext-apps/api/interfaces/app.McpUiResourceCsp.html#resourcedomains) in the UI resource metadata. diff --git a/examples/basic-server-vue/main.ts b/examples/basic-server-vue/main.ts index d774269..0304600 100644 --- a/examples/basic-server-vue/main.ts +++ b/examples/basic-server-vue/main.ts @@ -4,30 +4,41 @@ * 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"; +/** + * Shared utilities for running MCP servers with Streamable HTTP transport. + */ + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; import { createServer } from "./server.js"; +export interface ServerOptions { + port: number; + name?: string; +} + /** * Starts an MCP server with Streamable HTTP transport in stateless mode. * * @param createServer - Factory function that creates a new McpServer instance per request. + * @param options - Server configuration options. */ -export async function startStreamableHTTPServer( +export async function startServer( createServer: () => McpServer, + options: ServerOptions, ): Promise { - const port = parseInt(process.env.PORT ?? "3001", 10); + const { port, name = "MCP Server" } = options; 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({ + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); @@ -56,7 +67,7 @@ export async function startStreamableHTTPServer( console.error("Failed to start server:", err); process.exit(1); } - console.log(`MCP server listening on http://localhost:${port}/mcp`); + console.log(`${name} listening on http://localhost:${port}/mcp`); }); const shutdown = () => { @@ -68,22 +79,12 @@ export async function startStreamableHTTPServer( 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); + await createServer().connect(new StdioServerTransport()); } else { - await startStreamableHTTPServer(createServer); + const port = parseInt(process.env.PORT ?? "3001", 10); + await startServer(createServer, { port, name: "Basic MCP App Server (Vue)" }); } } diff --git a/examples/basic-server-vue/package.json b/examples/basic-server-vue/package.json index 858dd89..1dad261 100644 --- a/examples/basic-server-vue/package.json +++ b/examples/basic-server-vue/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-basic-vue", - "version": "2.0.0", + "version": "0.4.1", "type": "module", "description": "Basic MCP App Server example using Vue", "repository": { "type": "git", - "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", + "url": "https://github.com/modelcontextprotocol/ext-apps", "directory": "examples/basic-server-vue" }, "license": "MIT", @@ -17,27 +17,22 @@ "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\"", + "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", + "@modelcontextprotocol/ext-apps": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", "vue": "^3.5.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.0", - "@types/node": "22.10.0", + "@types/node": "^22.0.0", "@vitejs/plugin-vue": "^5.0.0", "concurrently": "^9.2.1", "cross-env": "^10.1.0", diff --git a/examples/basic-server-vue/server.ts b/examples/basic-server-vue/server.ts index 9adb911..73cb54d 100644 --- a/examples/basic-server-vue/server.ts +++ b/examples/basic-server-vue/server.ts @@ -1,12 +1,8 @@ -import { - McpServer, - type CallToolResult, - type ReadResourceResult, -} from "@modelcontextprotocol/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; import fs from "node:fs/promises"; import path from "node:path"; 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") @@ -32,8 +28,8 @@ export function createServer(): McpServer { { 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 + inputSchema: {}, + _meta: { ui: { resourceUri } }, }, async (): Promise => { const time = new Date().toISOString(); diff --git a/examples/basic-server-vue/src/App.vue b/examples/basic-server-vue/src/App.vue index a45c262..bfffaee 100644 --- a/examples/basic-server-vue/src/App.vue +++ b/examples/basic-server-vue/src/App.vue @@ -7,7 +7,7 @@ import { applyHostStyleVariables, type McpUiHostContext, } from "@modelcontextprotocol/ext-apps"; -import type { CallToolResult } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; function extractTime(result: CallToolResult): string { const { text } = result.content?.find((c) => c.type === "text")!; @@ -118,7 +118,7 @@ async function handleOpenLink() {

Watch activity in the DevTools console!

-

Server Time: {{ serverTime }}

+

Server Time: {{ serverTime }}

@@ -141,6 +141,10 @@ async function handleOpenLink() { diff --git a/examples/basic-server-vue/src/global.css b/examples/basic-server-vue/src/global.css index 801291f..97cda44 100644 --- a/examples/basic-server-vue/src/global.css +++ b/examples/basic-server-vue/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-vue/tsconfig.json b/examples/basic-server-vue/tsconfig.json index 4fd3cd7..5edb66a 100644 --- a/examples/basic-server-vue/tsconfig.json +++ b/examples/basic-server-vue/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-vue/tsconfig.server.json b/examples/basic-server-vue/tsconfig.server.json index 27eb28f..05ddd8e 100644 --- a/examples/basic-server-vue/tsconfig.server.json +++ b/examples/basic-server-vue/tsconfig.server.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/budget-allocator-server/README.md b/examples/budget-allocator-server/README.md index d4da76f..b81d2e1 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) diff --git a/examples/budget-allocator-server/main.ts b/examples/budget-allocator-server/main.ts index 02c03cb..534e876 100644 --- a/examples/budget-allocator-server/main.ts +++ b/examples/budget-allocator-server/main.ts @@ -4,30 +4,41 @@ * 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"; +/** + * Shared utilities for running MCP servers with Streamable HTTP transport. + */ + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; import { createServer } from "./server.js"; +export interface ServerOptions { + port: number; + name?: string; +} + /** * Starts an MCP server with Streamable HTTP transport in stateless mode. * * @param createServer - Factory function that creates a new McpServer instance per request. + * @param options - Server configuration options. */ -export async function startStreamableHTTPServer( +export async function startServer( createServer: () => McpServer, + options: ServerOptions, ): Promise { - const port = parseInt(process.env.PORT ?? "3001", 10); + const { port, name = "MCP Server" } = options; 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({ + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); @@ -56,7 +67,7 @@ export async function startStreamableHTTPServer( console.error("Failed to start server:", err); process.exit(1); } - console.log(`MCP server listening on http://localhost:${port}/mcp`); + console.log(`${name} listening on http://localhost:${port}/mcp`); }); const shutdown = () => { @@ -68,22 +79,12 @@ export async function startStreamableHTTPServer( 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); + await createServer().connect(new StdioServerTransport()); } else { - await startStreamableHTTPServer(createServer); + const port = parseInt(process.env.PORT ?? "3103", 10); + await startServer(createServer, { port, name: "Marketing" }); } } diff --git a/examples/budget-allocator-server/package.json b/examples/budget-allocator-server/package.json index 6e41622..2aed2b9 100644 --- a/examples/budget-allocator-server/package.json +++ b/examples/budget-allocator-server/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-budget-allocator", - "version": "2.0.0", + "version": "0.4.1", "type": "module", "description": "Budget allocator MCP App Server with interactive visualization", "repository": { "type": "git", - "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", + "url": "https://github.com/modelcontextprotocol/ext-apps", "directory": "examples/budget-allocator-server" }, "license": "MIT", @@ -16,29 +16,27 @@ "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", + "serve:http": "bun --watch server.ts", + "serve:stdio": "bun server.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\"", + "start:stdio": "cross-env NODE_ENV=development npm run build && 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" }, "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": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "chart.js": "^4.4.0", "cors": "^2.8.5", "express": "^5.1.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "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", "typescript": "^5.9.3", diff --git a/examples/budget-allocator-server/server.ts b/examples/budget-allocator-server/server.ts index 01f5dbf..e522918 100755 --- a/examples/budget-allocator-server/server.ts +++ b/examples/budget-allocator-server/server.ts @@ -4,11 +4,11 @@ * 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 type { + CallToolResult, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; @@ -269,8 +269,8 @@ export function createServer(): McpServer { { 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({}), + "Returns budget configuration with 24 months of historical allocations and industry benchmarks by company stage", + inputSchema: {}, outputSchema: BudgetDataResponseSchema, _meta: { ui: { resourceUri } }, }, diff --git a/examples/budget-allocator-server/src/mcp-app.ts b/examples/budget-allocator-server/src/mcp-app.ts index a9bae12..4e8c62d 100644 --- a/examples/budget-allocator-server/src/mcp-app.ts +++ b/examples/budget-allocator-server/src/mcp-app.ts @@ -3,7 +3,6 @@ */ import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; import { Chart, registerables } from "chart.js"; -import { z } from "zod"; import "./global.css"; import "./mcp-app.css"; @@ -627,295 +626,6 @@ function handleHostContextChanged(ctx: McpUiHostContext) { 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)") 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 index 27eb28f..05ddd8e 100644 --- a/examples/budget-allocator-server/tsconfig.server.json +++ b/examples/budget-allocator-server/tsconfig.server.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/cohort-heatmap-server/README.md b/examples/cohort-heatmap-server/README.md index c011933..322a116 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 diff --git a/examples/cohort-heatmap-server/main.ts b/examples/cohort-heatmap-server/main.ts index 8b33603..0b716d1 100644 --- a/examples/cohort-heatmap-server/main.ts +++ b/examples/cohort-heatmap-server/main.ts @@ -4,30 +4,41 @@ * 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"; +/** + * Shared utilities for running MCP servers with Streamable HTTP transport. + */ + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; import { createServer } from "./server.js"; +export interface ServerOptions { + port: number; + name?: string; +} + /** * Starts an MCP server with Streamable HTTP transport in stateless mode. * * @param createServer - Factory function that creates a new McpServer instance per request. + * @param options - Server configuration options. */ -export async function startStreamableHTTPServer( +export async function startServer( createServer: () => McpServer, + options: ServerOptions, ): Promise { - const port = parseInt(process.env.PORT ?? "3001", 10); + const { port, name = "MCP Server" } = options; 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({ + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); @@ -56,7 +67,7 @@ export async function startStreamableHTTPServer( console.error("Failed to start server:", err); process.exit(1); } - console.log(`MCP server listening on http://localhost:${port}/mcp`); + console.log(`${name} listening on http://localhost:${port}/mcp`); }); const shutdown = () => { @@ -68,22 +79,12 @@ export async function startStreamableHTTPServer( 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); + await createServer().connect(new StdioServerTransport()); } else { - await startStreamableHTTPServer(createServer); + const port = parseInt(process.env.PORT ?? "3104", 10); + await startServer(createServer, { port, name: "Cohort Heatmap Server" }); } } diff --git a/examples/cohort-heatmap-server/package.json b/examples/cohort-heatmap-server/package.json index 7e2d2e2..4a88842 100644 --- a/examples/cohort-heatmap-server/package.json +++ b/examples/cohort-heatmap-server/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-cohort-heatmap", - "version": "2.0.0", + "version": "0.4.1", "type": "module", "description": "Cohort heatmap MCP App Server for retention analysis", "repository": { "type": "git", - "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", + "url": "https://github.com/modelcontextprotocol/ext-apps", "directory": "examples/cohort-heatmap-server" }, "license": "MIT", @@ -16,30 +16,28 @@ "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", + "serve:http": "bun --watch server.ts", + "serve:stdio": "bun server.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\"", + "start:stdio": "cross-env NODE_ENV=development npm run build && 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" }, "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": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "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", diff --git a/examples/cohort-heatmap-server/server.ts b/examples/cohort-heatmap-server/server.ts index 129e2dc..6d07144 100644 --- a/examples/cohort-heatmap-server/server.ts +++ b/examples/cohort-heatmap-server/server.ts @@ -1,7 +1,5 @@ -import { - McpServer, - type ReadResourceResult, -} from "@modelcontextprotocol/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; @@ -180,8 +178,8 @@ export function createServer(): McpServer { title: "Get Cohort Retention Data", description: "Returns cohort retention heatmap data showing customer retention over time by signup month", - inputSchema: GetCohortDataInputSchema, - outputSchema: CohortDataSchema, + inputSchema: GetCohortDataInputSchema.shape, + outputSchema: CohortDataSchema.shape, _meta: { ui: { resourceUri } }, }, async ({ metric, periodType, cohortCount, maxPeriods }) => { diff --git a/examples/cohort-heatmap-server/tsconfig.json b/examples/cohort-heatmap-server/tsconfig.json index 3ecbdad..fc3c210 100644 --- a/examples/cohort-heatmap-server/tsconfig.json +++ b/examples/cohort-heatmap-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/cohort-heatmap-server/tsconfig.server.json b/examples/cohort-heatmap-server/tsconfig.server.json index 27eb28f..05ddd8e 100644 --- a/examples/cohort-heatmap-server/tsconfig.server.json +++ b/examples/cohort-heatmap-server/tsconfig.server.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/customer-segmentation-server/README.md b/examples/customer-segmentation-server/README.md index 8f5cc23..76a453e 100644 --- a/examples/customer-segmentation-server/README.md +++ b/examples/customer-segmentation-server/README.md @@ -4,51 +4,12 @@ A demo MCP App that displays customer data as an interactive scatter/bubble char - - - + + +
Account age vs revenueTickets, NPS, engagementAge, engagement, revenueAccount age vs revenueTickets, NPS, engagementAge, 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 diff --git a/examples/customer-segmentation-server/main.ts b/examples/customer-segmentation-server/main.ts index c788757..7be715a 100644 --- a/examples/customer-segmentation-server/main.ts +++ b/examples/customer-segmentation-server/main.ts @@ -4,30 +4,41 @@ * 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"; +/** + * Shared utilities for running MCP servers with Streamable HTTP transport. + */ + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; import { createServer } from "./server.js"; +export interface ServerOptions { + port: number; + name?: string; +} + /** * Starts an MCP server with Streamable HTTP transport in stateless mode. * * @param createServer - Factory function that creates a new McpServer instance per request. + * @param options - Server configuration options. */ -export async function startStreamableHTTPServer( +export async function startServer( createServer: () => McpServer, + options: ServerOptions, ): Promise { - const port = parseInt(process.env.PORT ?? "3001", 10); + const { port, name = "MCP Server" } = options; 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({ + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); @@ -56,7 +67,7 @@ export async function startStreamableHTTPServer( console.error("Failed to start server:", err); process.exit(1); } - console.log(`MCP server listening on http://localhost:${port}/mcp`); + console.log(`${name} listening on http://localhost:${port}/mcp`); }); const shutdown = () => { @@ -68,22 +79,15 @@ export async function startStreamableHTTPServer( 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); + await createServer().connect(new StdioServerTransport()); } else { - await startStreamableHTTPServer(createServer); + const port = parseInt(process.env.PORT ?? "3105", 10); + await startServer(createServer, { + port, + name: "Customer Segmentation Server", + }); } } diff --git a/examples/customer-segmentation-server/package.json b/examples/customer-segmentation-server/package.json index bac3b89..2a31eba 100644 --- a/examples/customer-segmentation-server/package.json +++ b/examples/customer-segmentation-server/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-customer-segmentation", - "version": "2.0.0", + "version": "0.4.1", "type": "module", "description": "Customer segmentation MCP App Server with filtering", "repository": { "type": "git", - "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", + "url": "https://github.com/modelcontextprotocol/ext-apps", "directory": "examples/customer-segmentation-server" }, "license": "MIT", @@ -16,29 +16,27 @@ "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", + "serve:http": "bun --watch server.ts", + "serve:stdio": "bun server.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\"", + "start:stdio": "cross-env NODE_ENV=development npm run build && 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" }, "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": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "chart.js": "^4.4.0", "cors": "^2.8.5", "express": "^5.1.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "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", "typescript": "^5.9.3", diff --git a/examples/customer-segmentation-server/server.ts b/examples/customer-segmentation-server/server.ts index 7cc1c1f..244415d 100644 --- a/examples/customer-segmentation-server/server.ts +++ b/examples/customer-segmentation-server/server.ts @@ -1,8 +1,8 @@ -import { - McpServer, - type CallToolResult, - type ReadResourceResult, -} from "@modelcontextprotocol/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { + CallToolResult, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; @@ -100,8 +100,8 @@ export function createServer(): McpServer { title: "Get Customer Data", description: "Returns customer data with segment information for visualization. Optionally filter by segment.", - inputSchema: GetCustomerDataInputSchema, - outputSchema: GetCustomerDataOutputSchema, + inputSchema: GetCustomerDataInputSchema.shape, + outputSchema: GetCustomerDataOutputSchema.shape, _meta: { ui: { resourceUri } }, }, async ({ segment }): Promise => { 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 index 27eb28f..05ddd8e 100644 --- a/examples/customer-segmentation-server/tsconfig.server.json +++ b/examples/customer-segmentation-server/tsconfig.server.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", 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/README.md b/examples/integration-server/README.md index d66ed74..ffa1d4e 100644 --- a/examples/integration-server/README.md +++ b/examples/integration-server/README.md @@ -7,8 +7,8 @@ An MCP App example used for E2E integration testing. 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) +- React UI using the [`useApp()`](https://modelcontextprotocol.github.io/ext-apps/api/functions/_modelcontextprotocol_ext-apps_react.useApp.html) hook +- 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), [`openLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#openlink) ## Key Files diff --git a/examples/integration-server/main.ts b/examples/integration-server/main.ts index 9a42842..5340e8a 100644 --- a/examples/integration-server/main.ts +++ b/examples/integration-server/main.ts @@ -4,30 +4,41 @@ * 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"; +/** + * Shared utilities for running MCP servers with Streamable HTTP transport. + */ + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; import { createServer } from "./server.js"; +export interface ServerOptions { + port: number; + name?: string; +} + /** * Starts an MCP server with Streamable HTTP transport in stateless mode. * * @param createServer - Factory function that creates a new McpServer instance per request. + * @param options - Server configuration options. */ -export async function startStreamableHTTPServer( +export async function startServer( createServer: () => McpServer, + options: ServerOptions, ): Promise { - const port = parseInt(process.env.PORT ?? "3001", 10); + const { port, name = "MCP Server" } = options; 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({ + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); @@ -56,7 +67,7 @@ export async function startStreamableHTTPServer( console.error("Failed to start server:", err); process.exit(1); } - console.log(`MCP server listening on http://localhost:${port}/mcp`); + console.log(`${name} listening on http://localhost:${port}/mcp`); }); const shutdown = () => { @@ -68,22 +79,12 @@ export async function startStreamableHTTPServer( 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); + await createServer().connect(new StdioServerTransport()); } else { - await startStreamableHTTPServer(createServer); + const port = parseInt(process.env.PORT ?? "3001", 10); + await startServer(createServer, { port, name: "Integration Test Server" }); } } diff --git a/examples/integration-server/package.json b/examples/integration-server/package.json index 7e04f4a..1c31e37 100644 --- a/examples/integration-server/package.json +++ b/examples/integration-server/package.json @@ -1,35 +1,32 @@ { "name": "integration-server", - "version": "2.0.0", + "version": "1.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", + "serve:http": "bun --watch server.ts", + "serve:stdio": "bun server.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\"", + "start:stdio": "cross-env NODE_ENV=development npm run build && 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", + "@modelcontextprotocol/ext-apps": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "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", diff --git a/examples/integration-server/server.ts b/examples/integration-server/server.ts index 346f4f7..a44c007 100644 --- a/examples/integration-server/server.ts +++ b/examples/integration-server/server.ts @@ -3,11 +3,11 @@ import { 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 type { + CallToolResult, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; @@ -16,7 +16,6 @@ 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. @@ -33,7 +32,7 @@ export function createServer(): McpServer { { title: "Get Time", description: "Returns the current server time.", - inputSchema: z.object({}), + inputSchema: {}, outputSchema: z.object({ time: z.string(), }), @@ -71,28 +70,5 @@ export function createServer(): McpServer { }, ); - // 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 index 801291f..97cda44 100644 --- a/examples/integration-server/src/global.css +++ b/examples/integration-server/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/integration-server/src/mcp-app.module.css b/examples/integration-server/src/mcp-app.module.css index ae08a8c..0c6429a 100644 --- a/examples/integration-server/src/mcp-app.module.css +++ b/examples/integration-server/src/mcp-app.module.css @@ -1,4 +1,8 @@ .main { + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + --color-notice-bg: #eff6ff; + width: 100%; max-width: 425px; box-sizing: border-box; @@ -9,7 +13,7 @@ } > * + * { - margin-top: var(--spacing-lg); + margin-top: 1.5rem; } } @@ -21,59 +25,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 */ -.serverTime { - flex: 1; - min-width: 0; -} diff --git a/examples/integration-server/src/mcp-app.tsx b/examples/integration-server/src/mcp-app.tsx index 281a356..7304ca9 100644 --- a/examples/integration-server/src/mcp-app.tsx +++ b/examples/integration-server/src/mcp-app.tsx @@ -3,7 +3,7 @@ */ import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps"; import { useApp } from "@modelcontextprotocol/ext-apps/react"; -import type { CallToolResult } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { StrictMode, useCallback, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import styles from "./mcp-app.module.css"; @@ -137,45 +137,6 @@ function GetTimeAppInner({ 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 (

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

@@ -221,16 +182,6 @@ function GetTimeAppInner({ /> - - {canDownload && ( -
-

Download file via EmbeddedResource or ResourceLink

-
- - -
-
- )}
); } diff --git a/examples/integration-server/tsconfig.json b/examples/integration-server/tsconfig.json index 3ecbdad..fc3c210 100644 --- a/examples/integration-server/tsconfig.json +++ b/examples/integration-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/integration-server/tsconfig.server.json b/examples/integration-server/tsconfig.server.json index 27eb28f..05ddd8e 100644 --- a/examples/integration-server/tsconfig.server.json +++ b/examples/integration-server/tsconfig.server.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/lazy-auth-server/README.md b/examples/lazy-auth-server/README.md deleted file mode 100644 index f2e7545..0000000 --- a/examples/lazy-auth-server/README.md +++ /dev/null @@ -1,105 +0,0 @@ -# Example: Lazy Auth Server - -An MCP App example demonstrating **lazy (on-demand) auth**: the server connects and lists tools without any authentication, and only asks for OAuth when a _protected_ tool is actually called โ€” by answering `401` with a `WWW-Authenticate` header. A public MCP App renders an "Auth me" button; clicking it calls a protected tool via [`callServerTool`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#callservertool). The host sees the 401, runs the OAuth flow, retries, and the result renders inline. - -The embedded OAuth authorization server is a deliberately minimal mock (HS256 JWTs, stateless auth codes, auto-approve consent page) so the whole flow runs from a single process with no external dependencies. It is **not** a production authorization server. - -## Tools - -| Tool | Auth | Description | -| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------- | -| `show_auth_button` | public | Renders buttons: "Auth me" (calls `get_secret`), "Revoke token" (calls `revoke_auth_token`) | -| `get_secret` | **protected** | Returns secret data (requires Bearer token) | -| `revoke_auth_token` | **protected** | Revokes the caller's **entire auth session** (access + refresh token) โ†’ forces full re-auth | -| `elicit_url` | public | URL elicitation via `elicitInput` (blocks until the elicitation completes) | -| `elicit_by_error` | public | URL elicitation via the `-32042` (`UrlElicitationRequired`) error; succeeds on retry after completion | - -## Getting Started - -```bash -npm install -npm start -# โ†’ MCP endpoint at http://localhost:3097/mcp -``` - -To test with a remote MCP host, expose the server through a public tunnel (see [Testing MCP Apps](../../docs/testing-mcp-apps.md)) and set `PUBLIC_URL` to the tunnel URL so OAuth metadata and callback URLs use it: - -```bash -PUBLIC_URL=https:// npm start -``` - -This example is HTTP-only (no stdio mode): the lazy-auth flow relies on HTTP status codes and OAuth endpoints. - -## Environment Variables - -| Var | Required | Description | -| --------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `JWT_SECRET` | recommended | 32+ byte secret for HS256 signing (`openssl rand -hex 32`). A dev-only default is used if unset. | -| `PORT` | no | Local port (default 3097) | -| `PUBLIC_URL` | non-local | Public base URL of the server (e.g. your tunnel URL). Required for non-localhost deployments; client-supplied `Host` headers are only trusted for loopback hosts | -| `ACCESS_TOKEN_TTL_SECONDS` | no | Default access-token lifetime (default **30**, short on purpose so you can watch the host's refresh flow kick in) | -| `REFRESH_TOKEN_TTL_SECONDS` | no | Default refresh-token lifetime (default **300**). Between the access and refresh TTLs, calls succeed via silent refresh; past it, a full re-auth is required | -| `REACTIVE_AUTH_ONLY` | no | Set `1` to remove auth metadata from the root `/.well-known/oauth-*` paths so hosts can't discover auth preemptively โ€” discovery then only happens via the 401 | - -### Per-connection token lifetimes - -The short defaults are great for watching the refresh flow, but slow or automated clients may want tokens that survive a whole session. Any client can request a different access-token lifetime by connecting to a TTL-scoped MCP endpoint path (capped at 24 hours): - -``` -https:///ttl/3600/mcp โ† tokens for this connection live 1 hour -``` - -This works through [RFC 8707 resource indicators](https://www.rfc-editor.org/rfc/rfc8707): MCP hosts send the MCP server URL as the `resource` parameter in OAuth authorization and token requests, and this server issues tokens for that grant with the lifetime encoded in the path (refresh tokens are extended to at least match). The TTL is a _path_ segment rather than a query param because hosts canonicalize resource indicators and strip query strings. Each TTL endpoint also enforces its value as a maximum token age, so connecting to a path with a _lower_ TTL than a token's issued lifetime forces the refresh flow. To exercise the full **re-auth** flow, call the `revoke_auth_token` tool. - -## Mounting under a base path - -`createApp()` can also be mounted inside another Express app, so an existing server can host this example at a sub-path of its own origin: - -```ts -import { createApp } from "@modelcontextprotocol/server-lazy-auth"; - -hostApp.use("/lazy-auth", createApp()); -// โ†’ MCP endpoint at https:///lazy-auth/mcp -``` - -All advertised URLs (OAuth metadata, `WWW-Authenticate` `resource_metadata`, PRM `resource`, elicitation callbacks) include the mount path automatically, derived from Express's `req.baseUrl`. When `PUBLIC_URL` is set, it must include the mount path (e.g. `https://example.com/lazy-auth`). - -One thing the mounted app cannot do for itself: [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414#section-3) / [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728) put well-known discovery documents at the _root_ of the origin with the path inserted after the well-known prefix (`/.well-known/oauth-authorization-server/lazy-auth`), and MCP SDK clients only try that insertion form. The host app must rewrite those root paths into the mount before its other routes: - -```ts -hostApp.use((req, _res, next) => { - const m = req.url.match( - /^\/\.well-known\/(oauth-authorization-server|oauth-protected-resource)\/lazy-auth(\/.*)?$/, - ); - if (m) req.url = `/lazy-auth/.well-known/${m[1]}${m[2] ?? ""}`; - next(); -}); -``` - -Rewriting into the mount (rather than calling the sub-app directly) keeps `req.baseUrl` โ€” and therefore every advertised URL โ€” consistent. - -## How It Works - -1. **Connect without auth** โ€” `initialize`, `tools/list`, and public tool calls succeed with no `Authorization` header. -2. **Protected tool โ†’ 401** โ€” when `get_secret` or `revoke_auth_token` is called without a (valid) Bearer token, the server responds `401` with `WWW-Authenticate: Bearer resource_metadata="โ€ฆ/auth/prm"`. -3. **Discovery** โ€” the host follows `resource_metadata` to the protected-resource metadata (RFC 9728), which points at the authorization server metadata (RFC 8414). -4. **OAuth flow** โ€” the host runs the authorization-code + PKCE flow against the mock `/authorize` and `/token` endpoints (a small consent page keeps the popup visible). -5. **Retry** โ€” the host retries the tool call with the Bearer token and the secret renders inline in the app. -6. **Refresh + revocation** โ€” access tokens expire after 30 seconds and refresh tokens after 5 minutes by default, so all three states are easy to observe: direct success (<30s), silent refresh (30sโ€“5min), and full re-auth (>5min). Connections can request different lifetimes via the `/ttl//mcp` endpoint path (see [Per-connection token lifetimes](#per-connection-token-lifetimes)), and `revoke_auth_token` invalidates the whole session immediately. - -The two `elicit_*` tools demonstrate the complementary pattern of [URL elicitation](https://modelcontextprotocol.io/specification/draft/client/elicitation), where the server asks the user to open a URL (e.g. to complete sign-in) either by blocking inside the tool call (`elicit_url`) or by failing with the `-32042` error and succeeding on retry (`elicit_by_error`). - -## Architecture - -- **Stateless auth codes** โ€” grant details are encoded _inside_ the authorization code as a 5-minute JWT, so nothing needs to be stored between requests. -- **Short-lived tokens** โ€” access tokens default to a **30 second** TTL and refresh tokens to **5 minutes**: first `get_secret` succeeds โ†’ wait >30s โ†’ next call 401s โ†’ host refreshes โ†’ retry succeeds โ†’ wait >5min โ†’ full re-auth. Per-connection overrides via the `/ttl//mcp` endpoint path. -- **HS256** โ€” a single shared secret; no key-pair persistence. -- **Per-request MCP server** โ€” each `/mcp` request gets a fresh `McpServer` + `StreamableHTTPServerTransport` (stateless, no session IDs). -- **Session revocation** โ€” all tokens from one OAuth session share a `sid` claim; `revoke_auth_token` adds the sid to an in-memory revocation list checked by both token verification and the refresh grant. - -## Key Files - -- [`server.ts`](server.ts) - OAuth endpoints, discovery metadata, and the MCP server with public + protected tools -- [`main.ts`](main.ts) - HTTP entry point -- [`mcp-app.html`](mcp-app.html) / [`src/mcp-app.ts`](src/mcp-app.ts) - Public app with the "Auth me" button -- [`secret-app.html`](secret-app.html) / [`src/secret-app.ts`](src/secret-app.ts) - Protected app rendered for `get_secret` diff --git a/examples/lazy-auth-server/main.ts b/examples/lazy-auth-server/main.ts deleted file mode 100644 index a96f34c..0000000 --- a/examples/lazy-auth-server/main.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Entry point for running the Lazy Auth demo MCP server. - * Run with: npx mcp-server-lazy-auth - * Or: node dist/index.js - * - * This example is HTTP-only (no stdio mode): the lazy-auth flow it demonstrates - * relies on HTTP status codes (401 + WWW-Authenticate) and OAuth endpoints. - * - * To test with a remote MCP host, expose the server through a public tunnel - * and set PUBLIC_URL to the tunnel URL so OAuth metadata and callback URLs - * use it (see docs/testing-mcp-apps.md in the repository root). - */ -import { createApp, PORT } from "./server.js"; - -async function main() { - const app = createApp(); - - const httpServer = app.listen(PORT, (err) => { - if (err) { - console.error("Failed to start server:", err); - process.exit(1); - } - console.log( - `Lazy Auth demo MCP server listening on http://localhost:${PORT}/mcp`, - ); - console.log( - ` Tools: show_auth_button, get_secret [PROTECTED], revoke_auth_token [PROTECTED], elicit_url, elicit_by_error`, - ); - }); - - const shutdown = () => { - console.log("\nShutting down..."); - httpServer.close(() => process.exit(0)); - }; - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); -} - -main().catch((e) => { - console.error(e); - process.exit(1); -}); diff --git a/examples/lazy-auth-server/mcp-app.html b/examples/lazy-auth-server/mcp-app.html deleted file mode 100644 index 7040f15..0000000 --- a/examples/lazy-auth-server/mcp-app.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - Lazy Auth Demo - - -
-

Public App

-

- No auth was needed to render this view. Click below to invoke the - protected - get_secret tool โ€” the host will run the OAuth flow on 401 - and retry. -

-
- - - -
-
-
- - - diff --git a/examples/lazy-auth-server/package.json b/examples/lazy-auth-server/package.json deleted file mode 100644 index dbd4601..0000000 --- a/examples/lazy-auth-server/package.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "name": "@modelcontextprotocol/server-lazy-auth", - "version": "2.0.0", - "type": "module", - "description": "MCP App example demonstrating lazy (on-demand) OAuth: public tools work unauthenticated, protected tools return 401 + WWW-Authenticate so the host runs the OAuth flow only when needed", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", - "directory": "examples/lazy-auth-server" - }, - "license": "MIT", - "main": "dist/server.js", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && cross-env INPUT=secret-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": "concurrently \"cross-env INPUT=mcp-app.html vite build --watch\" \"cross-env INPUT=secret-app.html vite build --watch\"", - "serve": "bun --watch main.ts", - "start": "cross-env NODE_ENV=development npm run build && npm run serve", - "dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"", - "prepublishOnly": "npm run build" - }, - "dependencies": { - "@modelcontextprotocol/ext-apps": "^2.0.0", - "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", - "cors": "^2.8.5", - "express": "^5.1.0", - "jose": "^6.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", - "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-lazy-auth": "dist/index.js" - } -} diff --git a/examples/lazy-auth-server/secret-app.html b/examples/lazy-auth-server/secret-app.html deleted file mode 100644 index b2ab00b..0000000 --- a/examples/lazy-auth-server/secret-app.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - Lazy Auth Demo โ€” Secret - - -
-

Protected App

-
Waiting for secret dataโ€ฆ
-
- - - diff --git a/examples/lazy-auth-server/server.ts b/examples/lazy-auth-server/server.ts deleted file mode 100644 index 5240008..0000000 --- a/examples/lazy-auth-server/server.ts +++ /dev/null @@ -1,1134 +0,0 @@ -/** - * Lazy Auth demo MCP server. - * - * Demonstrates "lazy" (on-demand) OAuth for MCP servers and Apps: - * - * - The server connects without authentication: `initialize`, `tools/list`, - * and public tool calls all succeed with no Bearer token. - * - Protected tools (`get_secret`, `revoke_auth_token`) return 401 with a - * `WWW-Authenticate` header pointing at protected-resource metadata, so the - * host only runs the OAuth flow when a protected tool is actually called. - * - The `show_auth_button` MCP App is public; clicking its button calls the - * protected `get_secret` tool via `callServerTool`. The host sees the 401, - * runs OAuth, retries, and the result renders inline. - * - * The embedded OAuth authorization server is intentionally minimal and - * stateless (HS256 JWTs, auth codes encoded as short-lived JWTs) so the demo - * can run anywhere with a single env var. It is NOT a production AS. - */ -import { - registerAppResource, - registerAppTool, - RESOURCE_MIME_TYPE, -} from "@modelcontextprotocol/ext-apps/server"; -import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node"; -import { - McpServer, - UrlElicitationRequiredError, - type CallToolResult, - type ReadResourceResult, -} from "@modelcontextprotocol/server"; -import cors from "cors"; -import express, { type Express, type Request, type Response } from "express"; -import { SignJWT, jwtVerify } from "jose"; -import crypto from "node:crypto"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { z } from "zod"; - -// Works both from source (server.ts) and compiled (dist/server.js). Derived -// from import.meta.url rather than import.meta.filename/dirname, which are -// undefined in some module-VM contexts (e.g. importing this package from -// jest). -const SERVER_FILE = fileURLToPath(import.meta.url); -const DIST_DIR = SERVER_FILE.endsWith(".ts") - ? path.join(path.dirname(SERVER_FILE), "dist") - : path.dirname(SERVER_FILE); - -// โ”€โ”€โ”€ Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -export const PORT = parseInt(process.env.PORT ?? "3097", 10); -const JWT_SECRET = new TextEncoder().encode( - process.env.JWT_SECRET ?? - "dev-insecure-secret-do-not-use-in-production-" + "x".repeat(20), -); -const PROTECTED_TOOLS = new Set(["get_secret", "revoke_auth_token"]); -// Token lifetimes. Defaults are deliberately short so the host's refresh flow -// (30sโ€“5min after auth) and full re-auth (>5min) are easy to observe in a demo. -// Slow or automated clients can request longer-lived tokens per connection by -// connecting to a TTL-scoped endpoint path (/ttl//mcp) โ€” see -// ttlFromResource() and the README. -const ACCESS_TOKEN_TTL_SECONDS = parseInt( - process.env.ACCESS_TOKEN_TTL_SECONDS ?? "30", - 10, -); -const REFRESH_TOKEN_TTL_SECONDS = parseInt( - process.env.REFRESH_TOKEN_TTL_SECONDS ?? "300", - 10, -); -// Cap on per-connection `?token_ttl_s=` requests. -const MAX_TOKEN_TTL_SECONDS = 86400; -// When true: root well-known PRM returns *stub* metadata WITHOUT authorization_servers, -// and root well-known AS is 404. Hosts probing well-known on connect see "resource has -// PRM but no auth configured" โ†’ connect without OAuth. Real PRM (with authorization_servers) -// is only at /auth/prm, reachable via WWW-Authenticate on 401. Default false = standard. -const REACTIVE_AUTH_ONLY = process.env.REACTIVE_AUTH_ONLY === "1"; - -/** Hostname is localhost / a loopback address (URL-style, so IPv6 keeps its brackets). */ -function isLoopbackHostname(hostname: string): boolean { - return ( - ["localhost", "127.0.0.1", "[::1]"].includes(hostname) || - hostname.endsWith(".localhost") - ); -} - -/** - * Resolve the public base URL for this deployment. - * - * Precedence: the PUBLIC_URL env var, then the request's Host header (loopback - * hosts only), then http://localhost:PORT. Host / X-Forwarded-* headers naming - * non-loopback hosts are deliberately NOT trusted โ€” a client could spoof them - * to make OAuth metadata and callback URLs point at a host it controls. - * Non-localhost deployments (e.g. behind a public tunnel) must set PUBLIC_URL - * explicitly. - */ -function resolvePublicUrl(req?: Request): URL { - // Mount path when this app is mounted inside another Express app - // (e.g. app.use("/lazy-auth", createApp())). Empty when standalone. - // PUBLIC_URL, when set, must already include any mount path. - const basePath = req?.baseUrl ?? ""; - const envUrl = process.env.PUBLIC_URL; - if (envUrl) return new URL(envUrl.endsWith("/") ? envUrl : envUrl + "/"); - const host = req?.headers.host; - if (host) { - try { - const url = new URL(`http://${host}${basePath}/`); - if (isLoopbackHostname(url.hostname)) return url; - } catch { - // Malformed Host header โ†’ fall through to the localhost default. - } - } - return new URL(`http://localhost:${PORT}${basePath}/`); -} - -/** Public base URL as a string with no trailing slash (may include a base path). */ -function publicBaseHref(req?: Request): string { - const href = resolvePublicUrl(req).href; - return href.endsWith("/") ? href.slice(0, -1) : href; -} - -/** OAuth issuer. In REACTIVE_AUTH_ONLY mode, uses a /auth subpath so root well-known 404s. - * Otherwise uses the public base URL (origin + any mount path). */ -const ISSUER_SUFFIX = REACTIVE_AUTH_ONLY ? "/auth" : ""; -function resolveIssuer(req?: Request): string { - return publicBaseHref(req) + ISSUER_SUFFIX; -} - -// โ”€โ”€โ”€ Mock OAuth (HS256, stateless codes) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -interface CodePayload { - client_id: string; - redirect_uri: string; - code_challenge?: string; - code_challenge_method?: string; - scope?: string; - /** Requested access-token TTL, from `?token_ttl_s=` on the resource indicator. */ - token_ttl_s?: number; -} - -/** Validate + cap a raw TTL value (from a path segment or query param). */ -function parseTtlSeconds(raw: string | undefined): number | undefined { - const ttl = parseInt(raw ?? "", 10); - return Number.isFinite(ttl) && ttl > 0 - ? Math.min(ttl, MAX_TOKEN_TTL_SECONDS) - : undefined; -} - -/** - * Extract the requested access-token TTL from a resource indicator (RFC 8707). - * - * MCP hosts send `resource=` โ€” the URL the client connected - * to โ€” in authorization and token requests. Clients opt into a non-default - * token lifetime by connecting to a TTL-scoped MCP endpoint path: - * - * /ttl//mcp (e.g. /ttl/3600/mcp for 1-hour tokens) - * - * The TTL lives in the PATH because hosts canonicalize resource indicators and - * strip query strings (verified against real hosts), so `?token_ttl_s=` query - * params would never reach this server's OAuth endpoints. The query param is - * still honored as a fallback for clients that preserve it. - */ -function ttlFromResource(resource: string | undefined): number | undefined { - if (!resource) return undefined; - try { - const url = new URL(resource); - const pathMatch = url.pathname.match(/\/ttl\/(\d+)\/mcp$/); - return ( - parseTtlSeconds(pathMatch?.[1]) ?? - parseTtlSeconds(url.searchParams.get("token_ttl_s") ?? undefined) - ); - } catch { - return undefined; - } -} - -interface AuthInfo { - token: string; - sub: string; - sid: string; -} - -// โ”€โ”€โ”€ Session revocation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -// -// Each OAuth session (authorize โ†’ code โ†’ tokens) gets a single `sid`; all access & -// refresh tokens in that session carry it. Revoking the sid invalidates both โ†’ host -// must do a full re-OAuth (refresh grant also fails). -// -// Storage is an in-memory Map, which is fine for a single-process demo server. If -// you deploy this to a multi-instance/serverless environment, back it with a -// shared store instead. - -const revokedSids = new Map(); // sid โ†’ unix GC time -// Revoked-sid entries need only outlive the longest token TTL; add small clock-skew grace. -const REVOCATION_GC_TTL_SECONDS = - Math.max( - ACCESS_TOKEN_TTL_SECONDS, - REFRESH_TOKEN_TTL_SECONDS, - MAX_TOKEN_TTL_SECONDS, - ) + 10; - -function revokeSession(sid: string): void { - revokedSids.set( - sid, - Math.floor(Date.now() / 1000) + REVOCATION_GC_TTL_SECONDS, - ); - const now = Math.floor(Date.now() / 1000); - for (const [k, gc] of revokedSids) if (gc < now) revokedSids.delete(k); -} - -function isSessionRevoked(sid: string | undefined): boolean { - if (!sid) return false; - return revokedSids.has(sid); -} - -async function signAccessToken( - sub: string, - scope: string, - sid: string, - issuer: string, - audience: string, - ttlSeconds: number = ACCESS_TOKEN_TTL_SECONDS, -): Promise { - return new SignJWT({ sub, scope, sid }) - .setProtectedHeader({ alg: "HS256" }) - .setIssuedAt() - .setIssuer(issuer) - .setAudience(audience) - .setExpirationTime(`${ttlSeconds}s`) - .sign(JWT_SECRET); -} - -async function signRefreshToken( - sub: string, - scope: string, - sid: string, - issuer: string, - ttlSeconds: number = REFRESH_TOKEN_TTL_SECONDS, - accessTtlSeconds?: number, -): Promise { - // access_ttl_s carries the grant's requested access-token TTL forward so - // refreshed access tokens keep the same lifetime as the original. - return new SignJWT({ - sub, - scope, - sid, - typ: "refresh", - ...(accessTtlSeconds !== undefined - ? { access_ttl_s: accessTtlSeconds } - : {}), - }) - .setProtectedHeader({ alg: "HS256" }) - .setIssuedAt() - .setIssuer(issuer) - .setExpirationTime(`${ttlSeconds}s`) - .sign(JWT_SECRET); -} - -async function verifyRefreshToken( - token: string, - issuer: string, -): Promise< - | { sub: string; scope: string; sid: string; accessTtlSeconds?: number } - | undefined -> { - try { - const { payload } = await jwtVerify(token, JWT_SECRET, { issuer }); - if (payload.typ !== "refresh") return undefined; - const sid = payload.sid as string | undefined; - if (isSessionRevoked(sid)) return undefined; // session revoked โ†’ refresh fails - return { - sub: payload.sub ?? "", - scope: (payload.scope as string) ?? "", - sid: sid ?? "", - accessTtlSeconds: - typeof payload.access_ttl_s === "number" - ? payload.access_ttl_s - : undefined, - }; - } catch { - return undefined; - } -} - -/** Encode grant details inside the code itself (5min expiry) โ€” no server storage. */ -async function signAuthCode( - payload: CodePayload, - issuer: string, -): Promise { - return new SignJWT({ ...payload, typ: "code" }) - .setProtectedHeader({ alg: "HS256" }) - .setIssuedAt() - .setJti(crypto.randomUUID()) - .setIssuer(issuer) - .setExpirationTime("5m") - .sign(JWT_SECRET); -} - -// Authorization codes are single-use (RFC 6749 ยง4.1.2): remember redeemed code -// IDs until the code's own 5-minute expiry makes replay impossible anyway. -const redeemedCodeJtis = new Map(); // jti โ†’ unix GC time - -async function verifyAuthCode( - code: string, - issuer: string, -): Promise { - try { - const { payload } = await jwtVerify(code, JWT_SECRET, { issuer }); - if (payload.typ !== "code" || !payload.jti) return undefined; - const now = Math.floor(Date.now() / 1000); - for (const [k, gc] of redeemedCodeJtis) - if (gc < now) redeemedCodeJtis.delete(k); - if (redeemedCodeJtis.has(payload.jti)) return undefined; // already redeemed - redeemedCodeJtis.set(payload.jti, now + 5 * 60 + 10); - return payload as unknown as CodePayload; - } catch { - return undefined; - } -} - -async function verifyAccessToken( - token: string, - issuer: string, - maxAgeSeconds?: number, -): Promise { - try { - const { payload } = await jwtVerify(token, JWT_SECRET, { issuer }); - if (payload.typ === "code" || payload.typ === "refresh") return undefined; // only plain access tokens - const sid = payload.sid as string | undefined; - if (isSessionRevoked(sid)) return undefined; // session revoked - // Per-connection TTL override (?token_ttl_s= on the MCP URL): treat tokens - // older than maxAgeSeconds as expired even though their exp claim is still - // in the future. Lets one deployment serve both long-TTL (default) and - // short-TTL (refresh-flow testing) connections. - if (maxAgeSeconds !== undefined && typeof payload.iat === "number") { - const ageSeconds = Math.floor(Date.now() / 1000) - payload.iat; - if (ageSeconds > maxAgeSeconds) return undefined; - } - return { token, sub: payload.sub ?? "", sid: sid ?? "" }; - } catch { - return undefined; - } -} - -function escapeHtml(s: string) { - return s.replace( - /[&<>"']/g, - (c) => - ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ - c - ]!, - ); -} - -/** - * This demo AS has no client registry, so it can't check redirect URIs against - * pre-registered values. At minimum, only allow https targets (or http on - * loopback for local development hosts) so the consent page never links to, - * or redirects the authorization code to, an unexpected scheme. - */ -function isAllowedRedirectUri(redirectUri: string): boolean { - try { - const url = new URL(redirectUri); - if (url.protocol === "https:") return true; - return url.protocol === "http:" && isLoopbackHostname(url.hostname); - } catch { - return false; - } -} - -async function handleAuthorize(req: Request, res: Response) { - const { - client_id, - redirect_uri, - state, - code_challenge, - code_challenge_method, - scope, - approved, - resource, - } = req.query as Record; - if (!redirect_uri) { - res.status(400).json({ - error: "invalid_request", - error_description: "Missing redirect_uri", - }); - return; - } - if (!isAllowedRedirectUri(redirect_uri)) { - res.status(400).json({ - error: "invalid_request", - error_description: "redirect_uri must use https (or http on localhost)", - }); - return; - } - // PKCE is mandatory (the MCP auth spec requires it of clients, and this AS - // only advertises S256). Rejecting up front keeps stolen-code attacks out of - // the demo even though it has no real data to protect. - if (!code_challenge || code_challenge_method !== "S256") { - res.status(400).json({ - error: "invalid_request", - error_description: "PKCE with S256 code_challenge is required", - }); - return; - } - const issuer = resolveIssuer(req); - - if (approved !== "1") { - // Show consent page. Keeps the OAuth popup visible so users can see the flow. - const approveUrl = new URL(publicBaseHref(req) + "/authorize"); - for (const [k, v] of Object.entries(req.query)) - if (v) approveUrl.searchParams.set(k, String(v)); - approveUrl.searchParams.set("approved", "1"); - const denyUrl = new URL(redirect_uri); - denyUrl.searchParams.set("error", "access_denied"); - if (state) denyUrl.searchParams.set("state", state); - res.type("text/html").send(/*html*/ ` -Authorize - - -
-

๐Ÿ”‘ Mock Authorization

-

An application is requesting access:

-
Client
${escapeHtml(client_id ?? "(none)")}
-
Scope
${escapeHtml(scope ?? "(default)")}
-
Redirect
${escapeHtml(redirect_uri)}
- -
- -`); - return; - } - - // Per-grant token TTL, requested via `?token_ttl_s=` on the resource - // indicator (the MCP URL the host is authorizing for). Encoded into the - // auth code so /token can issue tokens with the requested lifetime. - const tokenTtl = ttlFromResource(resource); - const code = await signAuthCode( - { - client_id, - redirect_uri, - code_challenge, - code_challenge_method, - scope, - ...(tokenTtl !== undefined ? { token_ttl_s: tokenTtl } : {}), - }, - issuer, - ); - const redirectUrl = new URL(redirect_uri); - redirectUrl.searchParams.set("code", code); - if (state) redirectUrl.searchParams.set("state", state); - console.log( - `[auth] issued code for client_id=${client_id}` + - (tokenTtl !== undefined ? ` (token_ttl_s=${tokenTtl})` : ""), - ); - res.redirect(redirectUrl.href); -} - -async function handleToken(req: Request, res: Response) { - const { grant_type, code, code_verifier, refresh_token, resource } = req.body; - const issuer = resolveIssuer(req); - const audience = resolvePublicUrl(req).href; - - if (grant_type === "refresh_token") { - const refreshClaims = await verifyRefreshToken(refresh_token, issuer); - if (!refreshClaims) { - console.log(`[auth] refresh rejected (invalid/revoked)`); - res.status(400).json({ - error: "invalid_grant", - error_description: "Refresh token invalid or revoked", - }); - return; - } - // Refreshed access tokens keep the grant's requested TTL: prefer the - // resource indicator on this request, else the TTL carried in the refresh - // token, else the default. - const accessTtl = - ttlFromResource(resource) ?? - refreshClaims.accessTtlSeconds ?? - ACCESS_TOKEN_TTL_SECONDS; - const access_token = await signAccessToken( - refreshClaims.sub, - refreshClaims.scope, - refreshClaims.sid, - issuer, - audience, - accessTtl, - ); - console.log( - `[auth] refreshed access token (sid=${refreshClaims.sid}, ttl=${accessTtl}s)`, - ); - res.json({ - access_token, - token_type: "Bearer", - expires_in: accessTtl, - scope: refreshClaims.scope, - refresh_token, - }); - return; - } - if (grant_type !== "authorization_code") { - res.status(400).json({ error: "unsupported_grant_type" }); - return; - } - const stored = await verifyAuthCode(code, issuer); - if (!stored) { - res.status(400).json({ - error: "invalid_grant", - error_description: "Invalid or expired code", - }); - return; - } - // PKCE verification (challenges are always present โ€” /authorize requires them). - if (!code_verifier) { - res.status(400).json({ - error: "invalid_grant", - error_description: "Missing code_verifier", - }); - return; - } - const hash = crypto - .createHash("sha256") - .update(code_verifier) - .digest("base64url"); - if (hash !== stored.code_challenge) { - res.status(400).json({ - error: "invalid_grant", - error_description: "PKCE verification failed", - }); - return; - } - // RFC 6749 ยง4.1.3 redirect_uri binding: if the client includes redirect_uri - // in the token request it must match the one from the authorization request. - // (OAuth 2.1 clients may omit it and rely on PKCE, which is enforced above.) - if (req.body.redirect_uri && req.body.redirect_uri !== stored.redirect_uri) { - res.status(400).json({ - error: "invalid_grant", - error_description: "redirect_uri does not match authorization request", - }); - return; - } - const scope = stored.scope ?? "read:secret"; - const sid = crypto.randomBytes(16).toString("hex"); // new session per authorization_code grant - // Per-grant TTL: prefer the resource indicator on this request (RFC 8707), - // else the TTL encoded in the auth code at /authorize, else the default. - // Refresh tokens must outlive the access tokens they can mint. - const accessTtl = - ttlFromResource(resource) ?? stored.token_ttl_s ?? ACCESS_TOKEN_TTL_SECONDS; - const refreshTtl = Math.max(REFRESH_TOKEN_TTL_SECONDS, accessTtl); - const access_token = await signAccessToken( - "mock-user-123", - scope, - sid, - issuer, - audience, - accessTtl, - ); - const refresh = await signRefreshToken( - "mock-user-123", - scope, - sid, - issuer, - refreshTtl, - accessTtl, - ); - console.log( - `[auth] exchanged code โ†’ token (client_id=${stored.client_id}, scope=${scope}, sid=${sid}, access_ttl=${accessTtl}s, refresh_ttl=${refreshTtl}s)`, - ); - res.json({ - access_token, - token_type: "Bearer", - expires_in: accessTtl, - scope, - refresh_token: refresh, - }); -} - -// โ”€โ”€โ”€ Elicitation state (module-level โ€” survives across SHTTP requests) โ”€โ”€โ”€โ”€โ”€โ”€ -// -// Streamable HTTP creates a fresh McpServer per request, so state must live at -// module scope. This is fine for a single-process demo server; back it with a -// shared store if you deploy multiple instances. - -/** elicitationIds the user has completed via /elicitation-callback */ -const completedElicitations = new Set(); -/** elicit_by_error: remember the last-issued eid so retries can check it */ -let lastIssuedElicitationId: string | undefined; - -// โ”€โ”€โ”€ MCP Server โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -/** - * Creates a new MCP server instance with tools and resources registered. - * - * @param authInfo - Verified Bearer token info, if the request carried one. - * @param req - The inbound HTTP request (used to resolve the public URL). - */ -export function createServer(authInfo?: AuthInfo, req?: Request): McpServer { - const server = new McpServer({ name: "Lazy Auth Demo", version: "1.0.0" }); - - const buttonUri = "ui://lazy-auth/mcp-app.html"; - registerAppTool( - server, - "show_auth_button", - { - title: "Show Auth Button", - description: - "Public tool: shows a button that triggers the protected get_secret tool.", - inputSchema: z.object({}), - _meta: { ui: { resourceUri: buttonUri } }, - }, - async (): Promise => ({ - content: [{ type: "text", text: "ok" }], - }), - ); - registerAppResource( - server, - buttonUri, - buttonUri, - { mimeType: RESOURCE_MIME_TYPE }, - async (): Promise => { - const html = await fs.readFile( - path.join(DIST_DIR, "mcp-app.html"), - "utf-8", - ); - return { - contents: [ - { uri: buttonUri, mimeType: RESOURCE_MIME_TYPE, text: html }, - ], - }; - }, - ); - - const secretUri = "ui://lazy-auth/secret-app.html"; - registerAppTool( - server, - "get_secret", - { - title: "Get Secret", - description: - "Protected tool: returns secret data. Requires authentication.", - inputSchema: z.object({}), - _meta: { ui: { resourceUri: secretUri } }, - }, - async (): Promise => { - if (!authInfo) { - return { - isError: true, - content: [{ type: "text", text: "Authentication required." }], - }; - } - const secret = { - subject: authInfo.sub, - secret: "the-answer-is-42", - issuedAt: new Date().toISOString(), - }; - return { - content: [{ type: "text", text: JSON.stringify(secret, null, 2) }], - }; - }, - ); - registerAppResource( - server, - secretUri, - secretUri, - { mimeType: RESOURCE_MIME_TYPE }, - async (): Promise => { - const html = await fs.readFile( - path.join(DIST_DIR, "secret-app.html"), - "utf-8", - ); - return { - contents: [ - { uri: secretUri, mimeType: RESOURCE_MIME_TYPE, text: html }, - ], - }; - }, - ); - - server.registerTool( - "revoke_auth_token", - { - title: "Revoke Auth Token", - description: - "Protected tool: revokes the caller's entire auth session (access + refresh token).", - inputSchema: z.object({}), - }, - async (): Promise => { - if (!authInfo) { - return { - isError: true, - content: [{ type: "text", text: "Authentication required." }], - }; - } - revokeSession(authInfo.sid); - console.log( - `[auth] revoked session sid=${authInfo.sid} (total revoked sessions: ${revokedSids.size})`, - ); - return { - content: [ - { - type: "text", - text: `Session revoked (sid: ${authInfo.sid}). Access + refresh token invalid โ†’ full re-auth required.`, - }, - ], - }; - }, - ); - - // โ”€โ”€ Elicitation test tools โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - // Public tools โ€” no auth. Used to exercise a host's URL-elicitation flow - // end-to-end over Streamable HTTP. - - const base = publicBaseHref(req); - const callbackUrl = (eid: string) => - `${base}/elicitation-callback?id=${encodeURIComponent(eid)}`; - - server.registerTool( - "elicit_url", - { - title: "Elicit URL (session-callback)", - description: - "URL elicitation via elicitInput. Blocks until the client accepts; sends ElicitCompleteNotification before returning.", - inputSchema: z.object({}), - }, - async (_args, extra): Promise => { - const eid = crypto.randomUUID(); - // Defer notifier creation โ€” checks client capabilities which throws if - // client doesn't advertise elicitation. Create lazily after elicitInput - // succeeds (implies client supports it). - console.log(`[elicit_url] eid=${eid} โ€” blocking on elicitInput`); - const result = await server.server.elicitInput( - { - mode: "url", - url: callbackUrl(eid), - message: "Please open this URL to continue.", - elicitationId: eid, - }, - { relatedRequestId: extra.mcpReq.id, timeout: 300_000 }, - ); - // Send completion notification before returning โ€” lands on the same SSE - // stream as this tool response. For Streamable HTTP this is the ONLY - // window where the server can push a notification (stream closes right - // after). The notification is optional per spec โ€” skip if the client - // doesn't support it. - try { - const notifier = server.server.createElicitationCompletionNotifier(eid); - await notifier(); - console.log( - `[elicit_url] eid=${eid} โ€” notified, result=${result.action}`, - ); - } catch (e) { - console.log( - `[elicit_url] eid=${eid} โ€” notify skipped: ${e instanceof Error ? e.message : e}, result=${result.action}`, - ); - } - return { - content: [ - { type: "text", text: `elicit_url: ${JSON.stringify(result)}` }, - ], - }; - }, - ); - - server.registerTool( - "elicit_by_error", - { - title: "Elicit by -32042 Error", - description: - "Throws UrlElicitationRequiredError (-32042). Client must open the URL, then retry. On retry, if the last-issued elicitationId was completed, sends ElicitCompleteNotification and succeeds.", - inputSchema: z.object({}), - }, - async (): Promise => { - // Retry path: check if the user completed the elicitation we last issued. - // Module-level state survives across Streamable HTTP requests in a single - // process. Checking ANY completed eid (not just last-issued) would be more - // forgiving, but last-issued is the stricter real-world pattern hosts must - // handle. - if ( - lastIssuedElicitationId && - completedElicitations.has(lastIssuedElicitationId) - ) { - const eid = lastIssuedElicitationId; - completedElicitations.delete(eid); - lastIssuedElicitationId = undefined; - // Send notification DURING this (retry) tool call โ€” only window where - // the Streamable HTTP stream is open. The notification is optional per - // spec โ€” createElicitationCompletionNotifier throws if the client - // doesn't advertise the capability, but the tool still succeeds. - try { - const notifier = - server.server.createElicitationCompletionNotifier(eid); - await notifier(); - console.log( - `[elicit_by_error] eid=${eid} โ€” completed on retry, notified`, - ); - } catch (e) { - console.log( - `[elicit_by_error] eid=${eid} โ€” completed on retry, notify skipped: ${e instanceof Error ? e.message : e}`, - ); - } - return { - content: [ - { - type: "text", - text: `Auth completed (${eid}). Tool succeeded on retry.`, - }, - ], - }; - } - const eid = crypto.randomUUID(); - lastIssuedElicitationId = eid; - console.log(`[elicit_by_error] eid=${eid} โ€” throwing -32042`); - throw new UrlElicitationRequiredError([ - { - mode: "url", - url: callbackUrl(eid), - message: - "This tool requires authentication. Please open the link to continue.", - elicitationId: eid, - }, - ]); - }, - ); - - return server; -} - -// โ”€โ”€โ”€ Express app โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -/** - * Creates the Express app: OAuth endpoints, discovery metadata, and the /mcp - * endpoint with lazy (401-triggered) auth on protected tools. - */ -export function createApp(): Express { - const app = express(); - // Wildcard CORS is deliberate: browser-based MCP hosts connect to this demo - // from arbitrary origins and must read WWW-Authenticate to drive the lazy - // auth flow. There are no cookies or ambient credentials to protect โ€” all - // auth is an explicit Bearer header, and /token is a credential-less PKCE - // exchange โ€” so cross-origin reads expose nothing a direct request wouldn't. - app.use(cors({ exposedHeaders: ["WWW-Authenticate"] })); - app.use(express.json()); - app.use(express.urlencoded({ extended: true })); - - // Request tracing: log every inbound request (incl. query params) so we can - // see what hosts probe during connect and what they send through OAuth. - app.use((req, _res, next) => { - const ua = (req.headers["user-agent"] ?? "").slice(0, 80); - const auth = req.headers.authorization ? "[Bearer]" : "[no-auth]"; - const query = Object.keys(req.query).length - ? ` query=${JSON.stringify(req.query)}` - : ""; - console.log(`[req] ${req.method} ${req.path} ${auth}${query} ua=${ua}`); - next(); - }); - - // OAuth endpoints - app.get("/authorize", handleAuthorize); - app.post("/token", handleToken); - - // OAuth discovery metadata. In REACTIVE_AUTH_ONLY mode, PRM + AS are kept off the - // ROOT /.well-known paths so MCP hosts can't proactively probe them and trigger - // preemptive auth during connection โ€” discovery is purely reactive: - // 401 โ†’ WWW-Authenticate โ†’ PRM โ†’ AS โ†’ OAuth flow. - const PRM_PATH = "/auth/prm"; - - function buildAsMetadata(req: Request) { - const base = publicBaseHref(req); - return { - issuer: resolveIssuer(req), // subpath issuer โ†’ well-known at /.well-known/.../auth - authorization_endpoint: `${base}/authorize`, - token_endpoint: `${base}/token`, - response_types_supported: ["code"], - grant_types_supported: ["authorization_code", "refresh_token"], - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: ["none"], - scopes_supported: ["read:secret"], - client_id_metadata_document_supported: true, - }; - } - - // AS metadata: serve at the issuer's well-known location. - // - REACTIVE_AUTH_ONLY: only at /.well-known/.../auth (subpath); root 404s. - // - Default: also at root /.well-known/... (standard; some hosts probe this on connect). - if (ISSUER_SUFFIX) { - // Per RFC 8414 ยง3: issuer /auth โ†’ /.well-known/oauth-authorization-server/auth - app.get( - `/.well-known/oauth-authorization-server${ISSUER_SUFFIX}`, - (req, res) => res.json(buildAsMetadata(req)), - ); - // RFC 8615 style (well-known between host and path): - app.get( - `${ISSUER_SUFFIX}/.well-known/oauth-authorization-server`, - (req, res) => res.json(buildAsMetadata(req)), - ); - } - if (!REACTIVE_AUTH_ONLY) { - // Default mode: full AS metadata at root well-known (standard). - app.get("/.well-known/oauth-authorization-server", (req, res) => - res.json(buildAsMetadata(req)), - ); - } - - // PRM: full version at custom path (referenced via WWW-Authenticate on 401). - function buildPrm(req: Request, includeAuth: boolean, resourcePath = "/mcp") { - const base = publicBaseHref(req); - return { - resource: `${base}${resourcePath}`, - ...(includeAuth - ? { - authorization_servers: [resolveIssuer(req)], - scopes_supported: ["read:secret"], - bearer_methods_supported: ["header"], - } - : {}), - }; - } - /** Resource path for a TTL-scoped MCP endpoint, validating the TTL segment. */ - function ttlResourcePath(rawTtl: string): string | undefined { - const ttl = parseTtlSeconds(rawTtl); - return ttl !== undefined ? `/ttl/${ttl}/mcp` : undefined; - } - app.get(PRM_PATH, (req, res) => res.json(buildPrm(req, true))); - app.get(`${PRM_PATH}/ttl/:ttl`, (req, res) => { - const resourcePath = ttlResourcePath(String(req.params.ttl)); - if (!resourcePath) { - res.status(404).json({ error: "invalid_ttl" }); - return; - } - res.json(buildPrm(req, true, resourcePath)); - }); - // Root + path-dependent well-known PRM: in REACTIVE mode these 404 so hosts don't - // interpret their presence as "server has OAuth". In default mode, full PRM. - if (!REACTIVE_AUTH_ONLY) { - app.get("/.well-known/oauth-protected-resource", (req, res) => - res.json(buildPrm(req, true)), - ); - app.get("/.well-known/oauth-protected-resource/mcp", (req, res) => - res.json(buildPrm(req, true)), - ); - // Well-known PRM for TTL-scoped MCP endpoints (RFC 9728 derives this URL - // from the resource path, so each TTL path has its own). - app.get( - "/.well-known/oauth-protected-resource/ttl/:ttl/mcp", - (req, res) => { - const resourcePath = ttlResourcePath(String(req.params.ttl)); - if (!resourcePath) { - res.status(404).json({ error: "invalid_ttl" }); - return; - } - res.json(buildPrm(req, true, resourcePath)); - }, - ); - } - - // /register: some MCP hosts fall through to OAuth dynamic client registration (RFC 7591) - // when all well-known probes 404. We don't support it โ€” return a proper OAuth error so - // the host concludes "auth not available here" and connects without OAuth, instead of - // treating a generic 404 as a connection failure. - app.post("/register", (_req, res) => { - res.status(400).json({ - error: "invalid_request", - error_description: - "Dynamic client registration is not supported. This server uses per-tool auth triggered via WWW-Authenticate on 401.", - }); - }); - - // MCP endpoint handler, shared by the default and TTL-scoped paths. - // pathTtl (from /ttl//mcp) doubles as the acceptance window for - // Bearer tokens: tokens older than it are rejected even if their issued - // lifetime was longer. - async function handleMcp( - req: Request, - res: Response, - pathTtl: number | undefined, - ) { - const base = publicBaseHref(req); - const resourceMetadataUrl = - pathTtl !== undefined - ? `${base}${PRM_PATH}/ttl/${pathTtl}` - : `${base}${PRM_PATH}`; - - const body = req.body; - const messages = Array.isArray(body) ? body : body ? [body] : []; - const needsAuth = messages.some( - (msg: { method?: string; params?: { name?: string } }) => - msg?.method === "tools/call" && - PROTECTED_TOOLS.has(msg.params?.name ?? ""), - ); - - // Acceptance window: the path TTL, else the `?token_ttl_s=` query param - // (fallback for clients that preserve query strings). - const tokenMaxAgeSeconds = - pathTtl ?? parseTtlSeconds(String(req.query.token_ttl_s ?? "")); - - let authInfo: AuthInfo | undefined; - const authHeader = req.headers.authorization; - if (authHeader?.startsWith("Bearer ")) { - authInfo = await verifyAccessToken( - authHeader.slice(7), - resolveIssuer(req), - tokenMaxAgeSeconds, - ); - if (!authInfo && needsAuth) { - console.log(`[mcp] 401: invalid token for protected tool`); - res - .status(401) - .set( - "WWW-Authenticate", - `Bearer error="invalid_token", error_description="The access token is invalid", resource_metadata="${resourceMetadataUrl}"`, - ) - .json({ - error: "invalid_token", - error_description: "The access token is invalid", - }); - return; - } - } else if (needsAuth) { - console.log(`[mcp] 401: no token for protected tool`); - res - .status(401) - .set( - "WWW-Authenticate", - `Bearer resource_metadata="${resourceMetadataUrl}"`, - ) - .json({ - error: "invalid_token", - error_description: "Authorization required", - }); - return; - } - - const server = createServer(authInfo, req); - 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, - }); - } - } - } - - // Default MCP endpoint (default token TTLs). - app.all("/mcp", (req: Request, res: Response) => - handleMcp(req, res, undefined), - ); - // TTL-scoped MCP endpoint: tokens for connections made through this path are - // issued with (and limited to) the requested lifetime, e.g. /ttl/3600/mcp. - app.all("/ttl/:ttl/mcp", (req: Request, res: Response) => { - const pathTtl = parseTtlSeconds(String(req.params.ttl)); - if (pathTtl === undefined) { - res.status(404).json({ error: "invalid_ttl" }); - return; - } - return handleMcp(req, res, pathTtl); - }); - - // Elicitation callback โ€” marks an eid as completed. Module-level state; - // the next elicit_by_error retry reads it. Minimal confirmation page so - // the popup shows something. - app.get("/elicitation-callback", (req, res) => { - const id = String(req.query.id ?? ""); - if (!id) { - res.status(400).type("text/plain").send("missing ?id="); - return; - } - completedElicitations.add(id); - console.log( - `[callback] eid=${id} completed (total: ${completedElicitations.size})`, - ); - res - .type("text/html") - .send( - `Done` + - `` + - `

โœ“ Elicitation completed

` + - `

${escapeHtml(id)}

` + - `

You can close this tab. The tool will succeed on the next retry.

`, - ); - }); - - // Simple landing page - app.get("/", (req, res) => { - const base = publicBaseHref(req); - res - .type("text/plain") - .send( - `Lazy Auth Demo โ€” MCP server\n\n` + - ` MCP endpoint: ${base}/mcp\n` + - ` ${base}/ttl//mcp (custom token TTL, e.g. /ttl/3600/mcp)\n` + - ` AS metadata: ${base}/.well-known/oauth-authorization-server${ISSUER_SUFFIX}\n` + - ` PRM metadata: ${base}${PRM_PATH}\n\n` + - `Tools:\n` + - ` - show_auth_button (public)\n` + - ` - get_secret (protected, requires Bearer token)\n` + - ` - revoke_auth_token (protected, revokes caller's auth session)\n` + - ` - elicit_url (public, URL elicitation via elicitInput)\n` + - ` - elicit_by_error (public, URL elicitation via -32042 error)\n`, - ); - }); - - return app; -} diff --git a/examples/lazy-auth-server/src/global.css b/examples/lazy-auth-server/src/global.css deleted file mode 100644 index 54a9860..0000000 --- a/examples/lazy-auth-server/src/global.css +++ /dev/null @@ -1,100 +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/lazy-auth-server/src/mcp-app.css b/examples/lazy-auth-server/src/mcp-app.css deleted file mode 100644 index 800b224..0000000 --- a/examples/lazy-auth-server/src/mcp-app.css +++ /dev/null @@ -1,82 +0,0 @@ -.main { - width: 100%; - max-width: 480px; - box-sizing: border-box; - - > * { - margin-top: 0; - margin-bottom: 0; - } - - > * + * { - margin-top: var(--spacing-md); - } -} - -.actions { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-sm); - /* Keep a long press on or near the buttons from starting text selection - or raising the iOS callout menu instead of a click. */ - -webkit-user-select: none; - user-select: none; - -webkit-touch-callout: none; -} - -button { - /* Match the consent page's 56px tap targets (server.ts .btn). */ - min-height: 56px; - min-width: 56px; - 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; - /* Suppress the double-tap-to-zoom gesture so a tap resolves to a click - immediately instead of waiting to see if a second tap follows. */ - touch-action: manipulation; - -webkit-tap-highlight-color: transparent; - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - &:active:not(:disabled) { - filter: brightness(0.85); - } - - &:hover:not(:disabled) { - 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); - } -} - -.result { - padding: var(--spacing-sm) var(--spacing-md); - border: var(--border-width-regular) solid - color-mix(in srgb, var(--color-text-primary) 25%, transparent); - border-radius: var(--border-radius-md); - font-family: var(--font-mono); - font-size: 0.875em; - white-space: pre-wrap; - word-break: break-word; -} - -.error { - color: var(--color-text-danger, light-dark(#b91c1c, #f87171)); - white-space: pre-wrap; - word-break: break-word; -} diff --git a/examples/lazy-auth-server/src/mcp-app.ts b/examples/lazy-auth-server/src/mcp-app.ts deleted file mode 100644 index f959d71..0000000 --- a/examples/lazy-auth-server/src/mcp-app.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * @file Public app view: buttons that call the protected tools. - * - * The view itself renders without auth. Clicking "Auth me" calls the protected - * `get_secret` tool via `callServerTool`; the host receives a 401 from the - * server, runs the OAuth flow, retries the call, and the result renders here. - */ -import { - App, - applyDocumentTheme, - applyHostFonts, - applyHostStyleVariables, - type McpUiHostContext, -} from "@modelcontextprotocol/ext-apps"; -import "./global.css"; -import "./mcp-app.css"; - -const mainEl = document.querySelector(".main") as HTMLElement; -const authBtn = document.getElementById("auth-btn") as HTMLButtonElement; -const revokeBtn = document.getElementById("revoke-btn") as HTMLButtonElement; -const fullscreenBtn = document.getElementById( - "fullscreen-btn", -) as HTMLButtonElement; -const outputEl = document.getElementById("output")!; - -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`; - } -} - -const app = new App( - { name: "Lazy Auth Demo", version: "1.0.0" }, - { availableDisplayModes: ["inline", "fullscreen"] }, -); - -// The host echoes the result of the originating `show_auth_button` call; nothing to render. -app.ontoolresult = () => {}; -app.onerror = console.error; -app.onhostcontextchanged = handleHostContextChanged; - -function renderOutput(text: string, isError: boolean) { - const div = document.createElement("div"); - div.className = isError ? "error" : "result"; - div.textContent = text; - outputEl.replaceChildren(div); -} - -/** - * Calls a (protected) server tool. The interesting part happens on the host: - * the server answers 401 + WWW-Authenticate, the host runs the OAuth flow, - * then retries the call and returns the result here. - * - * The default MCP request timeout is 60s; the OAuth flow involves a human - * approving a consent page, which can easily take longer. Use a generous - * timeout so the pending call survives the sign-in round trip. - */ -const AUTH_FLOW_TIMEOUT_MS = 5 * 60_000; - -async function callTool(name: string) { - outputEl.replaceChildren(); - try { - console.info(`Calling ${name} tool...`); - const result = await app.callServerTool( - { name, arguments: {} }, - { timeout: AUTH_FLOW_TIMEOUT_MS }, - ); - const text = - result.content?.find((c) => c.type === "text")?.text ?? "(no content)"; - renderOutput(text, result.isError === true); - } catch (e) { - renderOutput(String(e instanceof Error ? e.message : e), true); - } -} - -authBtn.addEventListener("click", async () => { - authBtn.disabled = true; - try { - await callTool("get_secret"); - } finally { - authBtn.disabled = false; - } -}); - -revokeBtn.addEventListener("click", async () => { - revokeBtn.disabled = true; - try { - await callTool("revoke_auth_token"); - } finally { - revokeBtn.disabled = false; - } -}); - -let displayMode = "inline"; -fullscreenBtn.addEventListener("click", async () => { - fullscreenBtn.disabled = true; - try { - const next = displayMode === "fullscreen" ? "inline" : "fullscreen"; - const { mode } = await app.requestDisplayMode({ mode: next }); - displayMode = mode; - } catch (e) { - console.warn("Display mode not supported:", e); - } finally { - fullscreenBtn.disabled = false; - } -}); - -app.connect().then(() => { - const ctx = app.getHostContext(); - if (ctx) { - handleHostContextChanged(ctx); - displayMode = ctx.displayMode ?? "inline"; - } -}); diff --git a/examples/lazy-auth-server/src/secret-app.ts b/examples/lazy-auth-server/src/secret-app.ts deleted file mode 100644 index aeb6857..0000000 --- a/examples/lazy-auth-server/src/secret-app.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @file Protected app view: renders the result of the `get_secret` tool. - * - * This view is only reached after the host has completed the OAuth flow - * (the tool call that produces it requires a Bearer token). - */ -import { - App, - applyDocumentTheme, - applyHostFonts, - applyHostStyleVariables, - type McpUiHostContext, -} from "@modelcontextprotocol/ext-apps"; -import "./global.css"; -import "./mcp-app.css"; - -const mainEl = document.querySelector(".main") as HTMLElement; -const outputEl = document.getElementById("output")!; - -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`; - } -} - -const app = new App({ name: "Lazy Auth Demo (Secret)", version: "1.0.0" }); - -app.onerror = console.error; -app.onhostcontextchanged = handleHostContextChanged; - -app.ontoolresult = (result) => { - const text = - result.content?.find((c) => c.type === "text")?.text ?? "(no content)"; - const div = document.createElement("div"); - div.className = result.isError ? "error" : "result"; - div.textContent = text; - outputEl.replaceChildren(div); -}; - -app.connect().then(() => { - const ctx = app.getHostContext(); - if (ctx) { - handleHostContextChanged(ctx); - } -}); diff --git a/examples/lazy-auth-server/tsconfig.json b/examples/lazy-auth-server/tsconfig.json deleted file mode 100644 index d6a42ce..0000000 --- a/examples/lazy-auth-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", "main.ts"] -} diff --git a/examples/lazy-auth-server/tsconfig.server.json b/examples/lazy-auth-server/tsconfig.server.json deleted file mode 100644 index 27eb28f..0000000 --- a/examples/lazy-auth-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/lazy-auth-server/vite.config.ts b/examples/lazy-auth-server/vite.config.ts deleted file mode 100644 index 6ff6d99..0000000 --- a/examples/lazy-auth-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/map-server/README.md b/examples/map-server/README.md index e1e044a..ad9223d 100644 --- a/examples/map-server/README.md +++ b/examples/map-server/README.md @@ -4,45 +4,6 @@ Interactive 3D globe viewer using CesiumJS with OpenStreetMap tiles. Demonstrates geocoding integration and full MCP App capabilities. -## MCP Client Configuration - -Add to your MCP client configuration (stdio transport): - -```json -{ - "mcpServers": { - "map": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-map", - "--stdio" - ] - } - } -} -``` - -### Local Development - -To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path): - -```json -{ - "mcpServers": { - "map": { - "command": "bash", - "args": [ - "-c", - "cd ~/code/ext-apps/examples/map-server && npm run build >&2 && node dist/index.js --stdio" - ] - } - } -} -``` - ## Features - **3D Globe Rendering**: Interactive CesiumJS globe with rotation, zoom, and 3D perspective @@ -122,7 +83,7 @@ Vanilla TypeScript app that: - [`server.ts`](server.ts) - MCP server with geocode and show-map tools - [`mcp-app.html`](mcp-app.html) / [`src/mcp-app.ts`](src/mcp-app.ts) - CesiumJS globe UI -- [`main.ts`](main.ts) - HTTP and stdio server utilities +- [`server-utils.ts`](server-utils.ts) - HTTP server utilities ## Notes diff --git a/examples/map-server/grid-cell.png b/examples/map-server/grid-cell.png index 41ebc22..c23bcf1 100644 Binary files a/examples/map-server/grid-cell.png and b/examples/map-server/grid-cell.png differ diff --git a/examples/map-server/main.ts b/examples/map-server/main.ts index 8038394..4495659 100644 --- a/examples/map-server/main.ts +++ b/examples/map-server/main.ts @@ -4,30 +4,41 @@ * 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"; +/** + * Shared utilities for running MCP servers with Streamable HTTP transport. + */ + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; import { createServer } from "./server.js"; +export interface ServerOptions { + port: number; + name?: string; +} + /** * Starts an MCP server with Streamable HTTP transport in stateless mode. * * @param createServer - Factory function that creates a new McpServer instance per request. + * @param options - Server configuration options. */ -export async function startStreamableHTTPServer( +export async function startServer( createServer: () => McpServer, + options: ServerOptions, ): Promise { - const port = parseInt(process.env.PORT ?? "3001", 10); + const { port, name = "MCP Server" } = options; 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({ + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); @@ -52,7 +63,7 @@ export async function startStreamableHTTPServer( }); const httpServer = app.listen(port, () => { - console.log(`MCP server listening on http://localhost:${port}/mcp`); + console.log(`${name} listening on http://localhost:${port}/mcp`); }); const shutdown = () => { @@ -64,22 +75,12 @@ export async function startStreamableHTTPServer( 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); + await createServer().connect(new StdioServerTransport()); } else { - await startStreamableHTTPServer(createServer); + const port = parseInt(process.env.PORT ?? "3001", 10); + await startServer(createServer, { port, name: "CesiumJS Map Server" }); } } diff --git a/examples/map-server/mcp-app.html b/examples/map-server/mcp-app.html index b19eed4..e46c7eb 100644 --- a/examples/map-server/mcp-app.html +++ b/examples/map-server/mcp-app.html @@ -1,90 +1,73 @@ - + - - - + + + CesiumJS Globe - - + +
Loading globe...
- + diff --git a/examples/map-server/package.json b/examples/map-server/package.json index cc4585f..c412df5 100644 --- a/examples/map-server/package.json +++ b/examples/map-server/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-map", - "version": "2.0.0", + "version": "0.4.1", "type": "module", "description": "MCP App Server example with CesiumJS 3D globe and geocoding", "repository": { "type": "git", - "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", + "url": "https://github.com/modelcontextprotocol/ext-apps", "directory": "examples/map-server" }, "license": "MIT", @@ -16,29 +16,26 @@ "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", + "serve:http": "bun --watch server.ts", + "serve:stdio": "bun server.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\"", + "start:stdio": "cross-env NODE_ENV=development npm run build && 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" }, "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", + "@modelcontextprotocol/ext-apps": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "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", "typescript": "^5.9.3", diff --git a/examples/map-server/screenshot.png b/examples/map-server/screenshot.png index 97c9191..d1d7079 100644 Binary files a/examples/map-server/screenshot.png and b/examples/map-server/screenshot.png differ diff --git a/examples/map-server/server.ts b/examples/map-server/server.ts index 5767d46..e6ff564 100644 --- a/examples/map-server/server.ts +++ b/examples/map-server/server.ts @@ -5,11 +5,11 @@ * - geocode: Search for places using OpenStreetMap Nominatim * - show-map: Display an interactive 3D globe at a given location */ -import { - McpServer, - type CallToolResult, - type ReadResourceResult, -} from "@modelcontextprotocol/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { + CallToolResult, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; @@ -17,6 +17,7 @@ import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, + RESOURCE_URI_META_KEY, } from "@modelcontextprotocol/ext-apps/server"; import { randomUUID } from "crypto"; @@ -24,7 +25,7 @@ import { randomUUID } from "crypto"; const DIST_DIR = import.meta.filename.endsWith(".ts") ? path.join(import.meta.dirname, "dist") : import.meta.dirname; -const resourceUri = "ui://cesium-map/mcp-app.html"; +const RESOURCE_URI = "ui://cesium-map/mcp-app.html"; // Nominatim API response type interface NominatimResult { @@ -117,8 +118,8 @@ export function createServer(): McpServer { // Register the CesiumJS map resource with CSP for external tile sources registerAppResource( server, - resourceUri, - resourceUri, + RESOURCE_URI, + RESOURCE_URI, { mimeType: RESOURCE_MIME_TYPE }, async (): Promise => { const html = await fs.readFile( @@ -127,9 +128,9 @@ export function createServer(): McpServer { ); return { contents: [ - // CSP metadata on the content item takes precedence over listing-level _meta + // _meta must be on the content item, not the resource metadata { - uri: resourceUri, + uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html, _meta: cspMeta, @@ -147,8 +148,8 @@ export function createServer(): McpServer { { title: "Show Map", description: - "Display an interactive world map zoomed to a specific bounding box. Use the GeoCode tool to find the bounding box of a location. The widget is interactive and exposes tools for navigation (fly to locations) and querying the current view.", - inputSchema: z.object({ + "Display an interactive world map zoomed to a specific bounding box. Use the GeoCode tool to find the bounding box of a location.", + inputSchema: { west: z .number() .optional() @@ -173,8 +174,8 @@ export function createServer(): McpServer { .string() .optional() .describe("Optional label to display on the map"), - }), - _meta: { ui: { resourceUri } }, + }, + _meta: { [RESOURCE_URI_META_KEY]: RESOURCE_URI }, }, async ({ west, south, east, north, label }): Promise => ({ content: [ @@ -184,7 +185,7 @@ export function createServer(): McpServer { }, ], _meta: { - viewUUID: randomUUID(), + widgetUUID: randomUUID(), }, }), ); @@ -196,13 +197,13 @@ export function createServer(): McpServer { title: "Geocode", description: "Search for places using OpenStreetMap. Returns coordinates and bounding boxes for up to 5 matches.", - inputSchema: z.object({ + inputSchema: { query: z .string() .describe( "Place name or address to search for (e.g., 'Paris', 'Golden Gate Bridge', '1600 Pennsylvania Ave')", ), - }), + }, }, async ({ query }): Promise => { try { diff --git a/examples/map-server/src/mcp-app.ts b/examples/map-server/src/mcp-app.ts index d3090c7..891ef2c 100644 --- a/examples/map-server/src/mcp-app.ts +++ b/examples/map-server/src/mcp-app.ts @@ -6,8 +6,6 @@ * a navigate-to tool for the host to control navigation. */ import { App } from "@modelcontextprotocol/ext-apps"; -import type { ContentBlock } from "@modelcontextprotocol/client"; -import { z } from "zod"; // TypeScript declaration for Cesium loaded from CDN // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -16,8 +14,6 @@ declare let Cesium: any; const CESIUM_VERSION = "1.123"; const CESIUM_BASE_URL = `https://cesium.com/downloads/cesiumjs/releases/${CESIUM_VERSION}/Build/Cesium`; -const MAX_MODEL_CONTEXT_UPDATE_IMAGE_DIMENSION = 768; // Max width/height for screenshots in pixels for updateModelContext - /** * Dynamically load CesiumJS from CDN * This is necessary because external - -""" +mcp = FastMCP("QR Code Server", port=PORT, stateless_http=True) -@mcp.tool(meta={ - "ui":{"resourceUri": VIEW_URI}, - "ui/resourceUri": VIEW_URI, # legacy support -}) +@mcp.tool(meta={"ui/resourceUri": WIDGET_URI}) def generate_qr( text: str = "https://modelcontextprotocol.io", box_size: int = 10, @@ -144,16 +72,52 @@ def generate_qr( return [types.ImageContent(type="image", data=b64, mimeType="image/png")] -# IMPORTANT: all the external domains used by app must be listed -# in the meta.ui.csp.resourceDomains - otherwise they will be blocked by CSP policy -@mcp.resource( - VIEW_URI, - mime_type="text/html;profile=mcp-app", - meta={"ui": {"csp": {"resourceDomains": ["https://unpkg.com"]}}}, -) -def view() -> str: - """View HTML resource with CSP metadata for external dependencies.""" - return EMBEDDED_VIEW_HTML +# Register widget resource using FastMCP decorator (returns HTML string) +@mcp.resource(WIDGET_URI, mime_type="text/html;profile=mcp-app") +def widget() -> str: + return Path(__file__).parent.joinpath("widget.html").read_text() + + +# Override the read_resource handler to inject _meta into the response +# This is needed because FastMCP doesn't support custom _meta on resources +_low_level_server = mcp._mcp_server + + +async def _read_resource_with_meta(req: types.ReadResourceRequest): + """Custom handler that injects CSP metadata for the widget resource.""" + uri = str(req.params.uri) + html = Path(__file__).parent.joinpath("widget.html").read_text() + + if uri == WIDGET_URI: + # NOTE: Must use model_validate with '_meta' key (not 'meta') due to Pydantic alias behavior + content = types.TextResourceContents.model_validate({ + "uri": WIDGET_URI, + "mimeType": "text/html;profile=mcp-app", + "text": html, + # IMPORTANT: all the external domains used by app must be listed + # in the _meta.ui.csp.resourceDomains - otherwise they will be blocked by CSP policy + "_meta": {"ui": {"csp": {"resourceDomains": ["https://unpkg.com"]}}} + }) + return types.ServerResult( + types.ReadResourceResult(contents=[content]) + ) + + # Fallback for other resources (shouldn't happen for this server) + return types.ServerResult( + types.ReadResourceResult( + contents=[ + types.TextResourceContents( + uri=uri, + mimeType="text/plain", + text="Resource not found" + ) + ] + ) + ) + + +# Replace the handler after FastMCP has registered its own +_low_level_server.request_handlers[types.ReadResourceRequest] = _read_resource_with_meta if __name__ == "__main__": if "--stdio" in sys.argv: diff --git a/examples/qr-server/widget.html b/examples/qr-server/widget.html new file mode 100644 index 0000000..10a594d --- /dev/null +++ b/examples/qr-server/widget.html @@ -0,0 +1,68 @@ + + + + + + + +
+ + + diff --git a/examples/quickstart/.gitignore b/examples/quickstart/.gitignore deleted file mode 100644 index b947077..0000000 --- a/examples/quickstart/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -dist/ diff --git a/examples/quickstart/README.md b/examples/quickstart/README.md deleted file mode 100644 index 74e070d..0000000 --- a/examples/quickstart/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Quickstart Server - -This is the example code for the [Quickstart guide](../../docs/quickstart.md). diff --git a/examples/quickstart/main.ts b/examples/quickstart/main.ts deleted file mode 100644 index 7f370a8..0000000 --- a/examples/quickstart/main.ts +++ /dev/null @@ -1,87 +0,0 @@ -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/quickstart/mcp-app.html b/examples/quickstart/mcp-app.html deleted file mode 100644 index bf276aa..0000000 --- a/examples/quickstart/mcp-app.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - Get Time App - - -

- Server Time: Loading... -

- - - - diff --git a/examples/quickstart/package.json b/examples/quickstart/package.json deleted file mode 100644 index 57f8444..0000000 --- a/examples/quickstart/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@modelcontextprotocol/quickstart", - "version": "2.0.0", - "type": "module", - "private": true, - "description": "Quickstart MCP App Server example", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", - "directory": "examples/quickstart" - }, - "license": "MIT", - "scripts": { - "build": "tsc --noEmit && tsc -p tsconfig.server.json && cross-env INPUT=mcp-app.html vite build", - "start": "concurrently --raw \"cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch\" \"tsx watch main.ts\"" - }, - "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", - "zod": "^4.2.0" - }, - "devDependencies": { - "@types/cors": "^2.8.19", - "@types/express": "^5.0.0", - "@types/node": "22.19.5", - "concurrently": "^9.2.1", - "cross-env": "^10.1.0", - "tsx": "^4.21.0", - "typescript": "^5.9.3", - "vite": "^6.0.0", - "vite-plugin-singlefile": "^2.3.0" - } -} diff --git a/examples/quickstart/server.ts b/examples/quickstart/server.ts deleted file mode 100644 index 611d0f0..0000000 --- a/examples/quickstart/server.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - registerAppResource, - registerAppTool, - RESOURCE_MIME_TYPE, -} from "@modelcontextprotocol/ext-apps/server"; -import { McpServer } from "@modelcontextprotocol/server"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { z } from "zod"; - -const DIST_DIR = path.join(import.meta.dirname, "dist"); - -/** - * Creates a new MCP server instance with tools and resources registered. - */ -export function createServer(): McpServer { - const server = new McpServer({ - name: "Quickstart MCP App Server", - version: "1.0.0", - }); - - // Two-part registration: tool + resource, tied together by the resource URI. - const resourceUri = "ui://get-time/mcp-app.html"; - - // Register a tool with UI metadata. When the host calls this tool, it reads - // `_meta.ui.resourceUri` to know which resource to fetch and render as an - // interactive UI. - registerAppTool( - server, - "get-time", - { - title: "Get Time", - description: "Returns the current server time.", - 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 }] }; - }, - ); - - // 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; -} diff --git a/examples/quickstart/src/mcp-app.ts b/examples/quickstart/src/mcp-app.ts deleted file mode 100644 index 5780bb7..0000000 --- a/examples/quickstart/src/mcp-app.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { App } from "@modelcontextprotocol/ext-apps"; - -// Get element references -const serverTimeEl = document.getElementById("server-time")!; -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. -app.ontoolresult = (result) => { - const time = result.content?.find((c) => c.type === "text")?.text; - 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; - serverTimeEl.textContent = time ?? "[ERROR]"; -}); - -// Connect to host -app.connect(); diff --git a/examples/quickstart/tsconfig.json b/examples/quickstart/tsconfig.json deleted file mode 100644 index d6a42ce..0000000 --- a/examples/quickstart/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", "main.ts"] -} diff --git a/examples/quickstart/tsconfig.server.json b/examples/quickstart/tsconfig.server.json deleted file mode 100644 index 72552e2..0000000 --- a/examples/quickstart/tsconfig.server.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "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 - }, - "include": ["server.ts", "main.ts"] -} diff --git a/examples/quickstart/vite.config.ts b/examples/quickstart/vite.config.ts deleted file mode 100644 index d34af53..0000000 --- a/examples/quickstart/vite.config.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { createLogger, 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, - }, -}); diff --git a/examples/run-all.ts b/examples/run-all.ts index 876655e..bff568a 100644 --- a/examples/run-all.ts +++ b/examples/run-all.ts @@ -6,9 +6,6 @@ * bun examples/run-all.ts start - Build and start all examples * bun examples/run-all.ts dev - Run all examples in dev/watch mode * bun examples/run-all.ts build - Build all examples - * - * Environment: - * EXAMPLE= - Run only a single example (e.g., EXAMPLE=say-server) */ import { readdirSync, statSync, existsSync } from "fs"; @@ -17,36 +14,21 @@ import concurrently from "concurrently"; const BASE_PORT = 3101; const BASIC_HOST = "basic-host"; -// Optional: filter to a single example via EXAMPLE env var (folder name) -const EXAMPLE_FILTER = process.env.EXAMPLE; - // Find all example directories except basic-host that have a package.json, // assign ports, and build URL list -const allServers = readdirSync("examples") +const servers = readdirSync("examples") .filter( (d) => d !== BASIC_HOST && statSync(`examples/${d}`).isDirectory() && existsSync(`examples/${d}/package.json`), ) - .sort(); // Sort for consistent port assignment - -// Filter servers if EXAMPLE is specified -const filteredDirs = EXAMPLE_FILTER - ? allServers.filter((d) => d === EXAMPLE_FILTER) - : allServers; - -if (EXAMPLE_FILTER && filteredDirs.length === 0) { - console.error(`Error: No example found matching EXAMPLE=${EXAMPLE_FILTER}`); - console.error(`Available examples: ${allServers.join(", ")}`); - process.exit(1); -} - -const servers = filteredDirs.map((dir, i) => ({ - dir, - port: BASE_PORT + i, - url: `http://localhost:${BASE_PORT + i}/mcp`, -})); + .sort() // Sort for consistent port assignment + .map((dir, i) => ({ + dir, + port: BASE_PORT + i, + url: `http://localhost:${BASE_PORT + i}/mcp`, + })); const COMMANDS = ["start", "dev", "build"]; @@ -61,9 +43,6 @@ if (!command || !COMMANDS.includes(command)) { const serversEnv = JSON.stringify(servers.map((s) => s.url)); console.log(`Running command: ${command}`); -if (EXAMPLE_FILTER) { - console.log(`Filtering to single example: ${EXAMPLE_FILTER}`); -} console.log( `Server examples: ${servers.map((s) => `${s.dir}:${s.port}`).join(", ")}`, ); diff --git a/examples/say-server/.gitignore b/examples/say-server/.gitignore deleted file mode 100644 index b947077..0000000 --- a/examples/say-server/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -dist/ diff --git a/examples/say-server/README.md b/examples/say-server/README.md deleted file mode 100644 index 3a28c41..0000000 --- a/examples/say-server/README.md +++ /dev/null @@ -1,215 +0,0 @@ -# Say Server - Streaming TTS MCP App - -A real-time text-to-speech MCP App with karaoke-style text highlighting, powered by [Kyutai's Pocket TTS](https://github.com/kyutai-labs/pocket-tts). - -![Screenshot](screenshot.png) - -## MCP Client Configuration - -Add to your MCP client configuration (stdio transport): - -```json -{ - "mcpServers": { - "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" - ] - } - } -} -``` - -### Local Development - -To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path): - -```json -{ - "mcpServers": { - "say": { - "command": "bash", - "args": [ - "-c", - "uv run --index https://pypi.org/simple ~/code/ext-apps/examples/say-server/server.py --stdio" - ] - } - } -} -``` - -## MCP App Features Demonstrated - -This example showcases several MCP App capabilities: - -- **Single-file executable**: Python server with embedded React UI - no build step required -- **Partial tool inputs** (`ontoolinputpartial`): The view receives streaming text as it's being generated -- **Queue-based streaming**: Demonstrates how to stream text out and audio in via a polling tool (adds text to an input queue, retrieves audio chunks from an output queue) -- **Model context updates**: The view updates the LLM with playback progress ("Playing: ...snippet...") -- **Native theming**: Uses CSS variables for automatic dark/light mode adaptation -- **Fullscreen mode**: Toggle fullscreen via `requestDisplayMode()` API, press Escape to exit -- **Multi-view speak lock**: Coordinates multiple TTS views via localStorage so only one plays at a time -- **Hidden tools** (`visibility: ["app"]`): Private tools only accessible to the view, not the model -- **External links** (`openLink`): Attribution popup uses `app.openLink()` to open external URLs -- **CSP metadata**: Resource declares required domains (`esm.sh`) for in-browser transpilation - -## Features - -- **Streaming TTS**: Audio starts playing as text is being generated -- **Karaoke highlighting**: Words are highlighted in sync with speech -- **Interactive controls**: Click to pause/resume, double-click to restart -- **Low latency**: Uses a polling-based queue for minimal delay - -## Prerequisites - -- [uv](https://docs.astral.sh/uv/) - fast Python package manager - -## Quick Start - -The server is a single self-contained Python file that can be run directly from GitHub: - -```bash -# Run directly from GitHub (uv auto-installs dependencies) -uv run https://raw.githubusercontent.com/modelcontextprotocol/ext-apps/main/examples/say-server/server.py -``` - -The server will be available at `http://localhost:3109/mcp`. - -## Running with Docker - -Run directly from GitHub using the official `uv` Docker image: - -```bash -docker run --rm -it \ - -p 3109:3109 \ - -v ~/.cache/huggingface-docker-say-server:/root/.cache/huggingface \ - ghcr.io/astral-sh/uv:debian \ - uv run https://raw.githubusercontent.com/modelcontextprotocol/ext-apps/main/examples/say-server/server.py -``` - -## Usage - -### With Claude Desktop - -Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`): - -```json -{ - "mcpServers": { - "say": { - "command": "uv", - "args": [ - "run", - "https://raw.githubusercontent.com/modelcontextprotocol/ext-apps/main/examples/say-server/server.py", - "--stdio" - ] - } - } -} -``` - -### With MCP Clients - -Connect to `http://localhost:3109/mcp` and call the `say` tool: - -```json -{ - "name": "say", - "arguments": { - "text": "Hello, world! This is a streaming TTS demo." - } -} -``` - -## Available Voices - -The default voice is `cosette`. Use the `list_voices` tool or pass a `voice` parameter to `say`: - -### Predefined Voices - -- `alba`, `marius`, `javert`, `jean` - from [alba-mackenna](https://huggingface.co/kyutai/tts-voices/tree/main/alba-mackenna) (CC BY 4.0) -- `cosette`, `eponine`, `azelma`, `fantine` - from [VCTK dataset](https://huggingface.co/kyutai/tts-voices/tree/main/vctk) (CC BY 4.0) - -### Custom Voices - -You can also use HuggingFace URLs or local file paths: - -```json -{"text": "Hello!", "voice": "hf://kyutai/tts-voices/voice-donations/alice.wav"} -{"text": "Hello!", "voice": "/path/to/my-voice.wav"} -``` - -See the [kyutai/tts-voices](https://huggingface.co/kyutai/tts-voices) repository for more voice collections - -## Architecture - -The entire server is contained in a single `server.py` file: - -1. **`say` tool**: Public tool that triggers the view with text to speak -2. **Private tools** (`create_tts_queue`, `add_tts_text`, `poll_tts_audio`, etc.): Hidden from the model, only callable by the view -3. **Embedded React view**: Uses [Babel standalone](https://babeljs.io/docs/babel-standalone) for in-browser JSX transpilation - no build step needed -4. **TTS backend**: Manages per-request audio queues using Pocket TTS - -The view communicates with the server via MCP tool calls: - -- Receives streaming text via `ontoolinputpartial` callback -- Incrementally sends new text to the server as it arrives (via `add_tts_text`) -- Polls for generated audio chunks while TTS runs in parallel -- Plays audio via Web Audio API with synchronized text highlighting - -## Multi-view Speak Lock - -When multiple TTS views exist in the same browser (e.g., multiple chat messages each with their own say view), they coordinate via localStorage to ensure only one plays at a time: - -1. **Unique view IDs**: Each view receives a UUID via `toolResult._meta.viewUUID` -2. **Announce on Play**: When starting, a view writes `{uuid, timestamp}` to `localStorage["mcp-tts-playing"]` -3. **Poll for Conflicts**: Every 200ms, playing views check if another view took the lock -4. **Yield Gracefully**: If another view started playing, pause and yield -5. **Clean Up**: On pause/finish, clear the lock (only if owned) - -This "last writer wins" protocol ensures a seamless experience: clicking play on any view immediately pauses others, without requiring cross-iframe postMessage coordination. - -## TODO - -- Persist caret position in localStorage (resume from where you left off) -- Click anywhere in text to move the cursor/playback position - -## Credits - -This project uses [Pocket TTS](https://github.com/kyutai-labs/pocket-tts) by [Kyutai](https://kyutai.org/) - a fantastic open-source text-to-speech model. Thank you to the Kyutai team for making this technology available! - -The server includes modified Pocket TTS code to support streaming text input (text can be fed incrementally while audio generation runs in parallel). A PR contributing this functionality back to the original repo is planned. - -## License - -This example is MIT licensed. - -### Third-Party Licenses - -This project uses the following open-source components: - -| Component | License | Link | -| --------------------------------------------------------------------- | ----------------- | ---------------------------- | -| [pocket-tts](https://github.com/kyutai-labs/pocket-tts) | MIT | Python TTS library | -| [Kyutai TTS model](https://huggingface.co/kyutai/tts-0.75b-en-public) | CC-BY 4.0 | Text-to-speech model weights | -| [kyutai/tts-voices](https://huggingface.co/kyutai/tts-voices) | Mixed (see below) | Voice prompt files | - -### Voice Collection Licenses - -The predefined voices in this example use **CC-BY 4.0** licensed collections: - -| Collection | License | Commercial Use | -| --------------- | ------------------- | ------------------------- | -| alba-mackenna | CC-BY 4.0 | โœ… Yes (with attribution) | -| vctk | CC-BY 4.0 | โœ… Yes (with attribution) | -| cml-tts/fr | CC-BY 4.0 | โœ… Yes (with attribution) | -| voice-donations | CC0 (Public Domain) | โœ… Yes | -| **expresso** | CC-BY-NC 4.0 | โŒ Non-commercial only | -| **ears** | CC-BY-NC 4.0 | โŒ Non-commercial only | - -โš ๏ธ **Note**: If you use voices from the `expresso/` or `ears/` collections, your use is restricted to non-commercial purposes. diff --git a/examples/say-server/grid-cell.png b/examples/say-server/grid-cell.png deleted file mode 100644 index ff2e85a..0000000 Binary files a/examples/say-server/grid-cell.png and /dev/null differ diff --git a/examples/say-server/mcp-app.html b/examples/say-server/mcp-app.html deleted file mode 100644 index 1e2389b..0000000 --- a/examples/say-server/mcp-app.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - Say View - - - -
- - - diff --git a/examples/say-server/package.json b/examples/say-server/package.json deleted file mode 100644 index cc12249..0000000 --- a/examples/say-server/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@modelcontextprotocol/server-say", - "version": "2.0.0", - "private": true, - "description": "Streaming TTS MCP App Server with karaoke-style text highlighting", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", - "directory": "examples/say-server" - }, - "license": "MIT", - "scripts": { - "start": "uv run --index https://pypi.org/simple server.py", - "dev": "uv run --index https://pypi.org/simple server.py", - "build": "echo 'No build step needed for Python server'" - }, - "dependencies": { - "@modelcontextprotocol/ext-apps": "^2.0.0" - } -} diff --git a/examples/say-server/screenshot.png b/examples/say-server/screenshot.png deleted file mode 100644 index 0ac8e6c..0000000 Binary files a/examples/say-server/screenshot.png and /dev/null differ diff --git a/examples/say-server/server.py b/examples/say-server/server.py deleted file mode 100755 index ce171d6..0000000 --- a/examples/say-server/server.py +++ /dev/null @@ -1,1487 +0,0 @@ -#!/usr/bin/env uv run --default-index https://pypi.org/simple -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "mcp>=1.26.0", -# "uvicorn>=0.34.0", -# "starlette>=0.46.0", -# "pocket-tts>=1.0.1", -# ] -# /// -""" -Say Demo - MCP App for streaming text-to-speech. - -This MCP server provides a "say" tool that speaks text using TTS. -The view receives streaming partial input and starts speaking immediately. - -Architecture: -- The `say` tool itself is a no-op - it just triggers the view -- The view uses `ontoolinputpartial` to receive text as it streams -- view calls private tools to create TTS queue, add text, and poll audio -- Audio plays in the view using Web Audio API -- Model context updates show playback progress to the LLM -- Native theming adapts to dark/light mode automatically -- Fullscreen mode with Escape key to exit -- Multi-View speak lock coordinates playback across instances - -Usage: - # Start the MCP server - python server.py - - # Or with stdio transport (for Claude Desktop) - python server.py --stdio -""" -from __future__ import annotations -import asyncio -import base64 -import logging -import os -import sys -import time -import uuid -from dataclasses import dataclass, field -from pathlib import Path -from typing import Annotated, Literal -from pydantic import Field - -import torch -import uvicorn -from mcp.server.fastmcp import FastMCP -from mcp import types -from mcp.types import Icon -from starlette.middleware.cors import CORSMiddleware - -from pocket_tts.models.tts_model import TTSModel, prepare_text_prompt -from pocket_tts.default_parameters import DEFAULT_AUDIO_PROMPT - -logger = logging.getLogger(__name__) - -VIEW_URI = "ui://say-demo/view.html" -HOST = os.environ.get("HOST", "0.0.0.0") -PORT = int(os.environ.get("PORT", "3001")) - -# Speaker icon as SVG data URI -SPEAKER_ICON = Icon( - src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2'%3E%3Cpolygon points='11 5 6 9 2 9 2 15 6 15 11 19 11 5'/%3E%3Cpath d='M15.54 8.46a5 5 0 0 1 0 7.07'/%3E%3Cpath d='M19.07 4.93a10 10 0 0 1 0 14.14'/%3E%3C/svg%3E", - mimeType="image/svg+xml", -) - -mcp = FastMCP("Say Demo", icons=[SPEAKER_ICON]) - -# Global TTS model (loaded on startup) -tts_model: TTSModel | None = None - - -# ------------------------------------------------------ -# TTS Queue State Management -# ------------------------------------------------------ - -@dataclass -class AudioChunkData: - """Audio chunk with timing metadata.""" - index: int - audio_base64: str - char_start: int - char_end: int - duration_ms: float - - -@dataclass -class TTSQueueState: - """State for a TTS generation queue.""" - id: str - voice: str - sample_rate: int - status: Literal["active", "complete", "error"] = "active" - error_message: str | None = None - - # Text queue - text_queue: asyncio.Queue = field(default_factory=asyncio.Queue) - end_signaled: bool = False - - # Audio output - audio_chunks: list[AudioChunkData] = field(default_factory=list) - chunks_delivered: int = 0 - - # Tracking - created_at: float = field(default_factory=time.time) - last_activity: float = field(default_factory=time.time) # Last text or end signal - lock: asyncio.Lock = field(default_factory=asyncio.Lock) - task: asyncio.Task | None = None - - -# Active TTS queues -tts_queues: dict[str, TTSQueueState] = {} - -# Queue timeout: if no activity for this long, mark as error -QUEUE_TIMEOUT_SECONDS = 30 - - -# ------------------------------------------------------ -# Public Tool: say -# ------------------------------------------------------ - -DEFAULT_TEXT = """Hello! I'm a text-to-speech demonstration. This speech is being generated in real-time as you watch. The words you see highlighted are synchronized with the audio playback, creating a karaoke-style reading experience. You can click to pause or resume, and use the reset button to restart from the beginning. Pretty neat, right?""" - -# Predefined voices from pocket-tts (mapped to HuggingFace files) -# See: https://huggingface.co/kyutai/tts-voices -PREDEFINED_VOICES = { - "alba": "hf://kyutai/tts-voices/alba-mackenna/casual.wav", - "marius": "hf://kyutai/tts-voices/alba-mackenna/merchant.wav", - "javert": "hf://kyutai/tts-voices/alba-mackenna/announcer.wav", - "jean": "hf://kyutai/tts-voices/alba-mackenna/a-moment-by.wav", - "fantine": "hf://kyutai/tts-voices/vctk/p225_023_mic1.wav", - "cosette": "hf://kyutai/tts-voices/vctk/p226_023_mic1.wav", - "eponine": "hf://kyutai/tts-voices/vctk/p227_023_mic1.wav", - "azelma": "hf://kyutai/tts-voices/vctk/p228_023_mic1.wav", -} - -DEFAULT_VOICE = "cosette" - - -@mcp.tool() -def list_voices() -> list[types.TextContent]: - """List available TTS voices. - - Returns the predefined voice names that can be used with the say tool. - You can also use HuggingFace URLs (hf://kyutai/tts-voices/...) or local file paths. - """ - import json - voice_info = { - "predefined_voices": list(PREDEFINED_VOICES.keys()), - "default_voice": DEFAULT_VOICE, - "custom_voice_formats": [ - "hf://kyutai/tts-voices//.wav", - "/path/to/local/voice.wav", - ], - "collections": [ - "alba-mackenna (CC-BY 4.0) - voice-acted characters", - "vctk (CC-BY 4.0) - VCTK dataset speakers", - "cml-tts/fr (CC-BY 4.0) - French voices", - "voice-donations (CC0) - public domain community voices", - "expresso (CC-BY-NC 4.0) - expressive (NON-COMMERCIAL ONLY)", - "ears (CC-BY-NC 4.0) - emotional (NON-COMMERCIAL ONLY)", - ], - } - return [types.TextContent(type="text", text=json.dumps(voice_info, indent=2))] - - -@mcp.tool(meta={ - "ui":{"resourceUri": VIEW_URI}, - "ui/resourceUri": VIEW_URI, # legacy support -}) -def say( - text: Annotated[str, Field(description="The English text to speak aloud")] = DEFAULT_TEXT, - voice: Annotated[str, Field( - description="Voice to use. Can be a predefined name (alba, marius, cosette, etc.), " - "a HuggingFace URL (hf://kyutai/tts-voices/...), or a local file path." - )] = DEFAULT_VOICE, - autoPlay: Annotated[bool, Field( - description="Whether to start playing automatically. Note: browsers may block autoplay until user interaction." - )] = True, -) -> list[types.TextContent]: - """Speak English text aloud using text-to-speech. - - Use when the user wants text read or spoken aloud: - - "say ...", "speak ...", "read ... out loud" - - "...; say it", "...; read it to me", "...; speak it" - - "narrate ...", "read this aloud" - - Audio streams in real-time as text is provided. - Use list_voices() for voice options. - - Note: English only. Non-English text may produce poor or garbled results. - """ - # Generate a unique ID for this view instance (used for speak lock coordination) - view_uuid = uuid.uuid4().hex[:12] - - # This is a no-op - the view handles everything via ontoolinputpartial - # The tool exists to: - # 1. Trigger the view to load - # 2. Provide the resourceUri metadata - # 3. Show the final text in the tool result - # 4. Provide view UUID for multi-player coordination - return [types.TextContent( - type="text", - text=f"Displayed a TTS view with voice '{voice}'. Click to play/pause, use toolbar to restart or fullscreen.", - _meta={"viewUUID": view_uuid}, - )] - - -# ------------------------------------------------------ -# Private Tools: TTS Queue Management -# ------------------------------------------------------ - -@mcp.tool(meta={"ui":{"visibility":["app"]}}) -async def create_tts_queue(voice: str = "cosette") -> list[types.TextContent]: - """Create a TTS generation queue. Returns queue_id and sample_rate. - - Args: - voice: Voice to use (cosette, alba, brenda, etc.) - """ - if tts_model is None: - return [types.TextContent(type="text", text='{"error": "TTS model not loaded"}')] - - queue_id = uuid.uuid4().hex[:12] - sample_rate = tts_model.config.mimi.sample_rate - - state = TTSQueueState( - id=queue_id, - voice=voice, - sample_rate=sample_rate, - ) - tts_queues[queue_id] = state - - # Start background TTS processing task - state.task = asyncio.create_task(_run_tts_queue(state)) - - logger.info(f"Created TTS queue {queue_id}") - - import json - return [types.TextContent( - type="text", - text=json.dumps({"queue_id": queue_id, "sample_rate": sample_rate}) - )] - - -@mcp.tool(meta={"ui":{"visibility":["app"]}}) -def add_tts_text(queue_id: str, text: str) -> list[types.TextContent]: - """Add text to a TTS queue. - - Args: - queue_id: The queue ID from create_tts_queue - text: Text to add (incremental, not cumulative) - """ - state = tts_queues.get(queue_id) - if not state: - return [types.TextContent(type="text", text='{"error": "Queue not found"}')] - if state.end_signaled: - return [types.TextContent(type="text", text='{"error": "Queue already ended"}')] - - # Queue the text (non-blocking) - try: - state.text_queue.put_nowait(text) - state.last_activity = time.time() # Update activity timestamp - except asyncio.QueueFull: - return [types.TextContent(type="text", text='{"error": "Queue full"}')] - - return [types.TextContent(type="text", text='{"queued": true}')] - - -@mcp.tool(meta={"ui":{"visibility":["app"]}}) -def end_tts_queue(queue_id: str) -> list[types.TextContent]: - """Signal that no more text will be sent to a queue. - - Args: - queue_id: The queue ID from create_tts_queue - """ - state = tts_queues.get(queue_id) - if not state: - logger.warning(f"end_tts_queue called for unknown queue: {queue_id}") - return [types.TextContent(type="text", text='{"error": "Queue not found"}')] - if state.end_signaled: - logger.info(f"end_tts_queue called for already-ended queue: {queue_id}") - return [types.TextContent(type="text", text='{"already_ended": true}')] - - state.end_signaled = True - state.last_activity = time.time() # Update activity timestamp - try: - state.text_queue.put_nowait(None) # EOF marker - except asyncio.QueueFull: - pass - - logger.info(f"end_tts_queue called for queue: {queue_id}") - return [types.TextContent(type="text", text='{"ended": true}')] - - -@mcp.tool(meta={"ui":{"visibility":["app"]}}) -def cancel_tts_queue(queue_id: str) -> list[types.TextContent]: - """Cancel and cleanup a TTS queue. Use before creating a new queue to avoid overlapping playback. - - Args: - queue_id: The queue ID from create_tts_queue - """ - state = tts_queues.pop(queue_id, None) - if not state: - return [types.TextContent(type="text", text='{"error": "Queue not found"}')] - - # Cancel the background task - if state.task and not state.task.done(): - state.task.cancel() - logger.info(f"Cancelled TTS queue {queue_id}") - - # Signal end to unblock any waiting consumers - state.end_signaled = True - try: - state.text_queue.put_nowait(None) - except asyncio.QueueFull: - pass - - state.status = "complete" - - return [types.TextContent(type="text", text='{"cancelled": true}')] - - -@mcp.tool(meta={"ui":{"visibility":["app"]}}) -def poll_tts_audio(queue_id: str) -> list[types.TextContent]: - """Poll for available audio chunks from a TTS queue. - - Returns base64-encoded audio chunks with timing metadata. - Call repeatedly until done=true. - - Args: - queue_id: The queue ID from create_tts_queue - """ - import json - import time - - state = tts_queues.get(queue_id) - if not state: - return [types.TextContent(type="text", text='{"error": "Queue not found"}')] - - # Update last activity to prevent timeout during active polling - state.last_activity = time.time() - - # Get new chunks (use sync approach since we can't await in tool) - # The lock is async, so we need to be careful here - # For simplicity, just grab what's available without locking - new_chunks = state.audio_chunks[state.chunks_delivered:] - state.chunks_delivered = len(state.audio_chunks) - - # Consider queues with errors as "done" so view stops polling - done = (state.status == "complete" or state.status == "error") and state.chunks_delivered >= len(state.audio_chunks) - - response = { - "chunks": [ - { - "index": c.index, - "audio_base64": c.audio_base64, - "char_start": c.char_start, - "char_end": c.char_end, - "duration_ms": c.duration_ms, - } - for c in new_chunks - ], - "done": done, - "status": state.status, - } - - # Include error message if present - if state.error_message: - response["error"] = state.error_message - - # Clean up completed or errored queues - if done: - # Schedule cleanup after a delay - async def cleanup(): - await asyncio.sleep(60) - tts_queues.pop(queue_id, None) - try: - asyncio.get_event_loop().create_task(cleanup()) - except RuntimeError: - pass - - return [types.TextContent(type="text", text=json.dumps(response))] - - -# ------------------------------------------------------ -# Background TTS Processing -# ------------------------------------------------------ - - -class StreamingTextChunker: - """Buffers streaming text and emits chunks when ready for TTS processing. - - Chunks are emitted when: - - Token count reaches max_tokens threshold (at a sentence boundary if possible) - - flush() is called (end of stream) - - This matches the chunking behavior of split_into_best_sentences() but works - incrementally as text arrives. - """ - - def __init__(self, tokenizer, max_tokens: int = 50, min_tokens: int = 15): - """ - Args: - tokenizer: SentencePiece tokenizer from flow_lm.conditioner.tokenizer - max_tokens: Maximum tokens per chunk (default 50, matches existing) - min_tokens: Minimum tokens before considering emission - """ - self.tokenizer = tokenizer - self.max_tokens = max_tokens - self.min_tokens = min_tokens - self.buffer = "" - - # Cache end-of-sentence token IDs for boundary detection - _, *eos_tokens = tokenizer(".!...?").tokens[0].tolist() - self.eos_tokens = set(eos_tokens) - - def add_text(self, text: str) -> list[str]: - """Add text to buffer, return any complete chunks ready for processing. - - Args: - text: Incremental text to add (e.g., from LLM token) - - Returns: - List of text chunks ready for TTS (may be empty if still buffering) - """ - self.buffer += text - return self._extract_ready_chunks() - - def flush(self) -> list[str]: - """Flush remaining buffer as final chunk(s). - - Call this when the text stream ends to process any remaining text. - - Returns: - List of final text chunks (may be empty if buffer was empty) - """ - if not self.buffer.strip(): - return [] - - # Force emit whatever remains - chunks = self._extract_ready_chunks(force_emit=True) - if self.buffer.strip(): - chunks.append(self.buffer.strip()) - self.buffer = "" - return chunks - - def _extract_ready_chunks(self, force_emit: bool = False) -> list[str]: - """Extract chunks that are ready for processing.""" - chunks = [] - - while True: - chunk = self._try_extract_chunk(force_emit and not chunks) - if chunk is None: - break - chunks.append(chunk) - - return chunks - - def _try_extract_chunk(self, force_emit: bool = False) -> str | None: - """Try to extract one chunk from buffer.""" - text = self.buffer.strip() - if not text: - return None - - tokens = self.tokenizer(text).tokens[0].tolist() - num_tokens = len(tokens) - - # Not enough tokens yet - if num_tokens < self.min_tokens and not force_emit: - return None - - # Under max and not forcing - check for complete sentence worth emitting - if num_tokens < self.max_tokens and not force_emit: - # Only emit early if we have a complete sentence at a good length - if num_tokens >= self.min_tokens and self._ends_with_sentence_boundary(tokens): - # Found a complete sentence - emit it - chunk = text - self.buffer = "" - return chunk - return None - - # Over max_tokens or force_emit - find best split point - split_idx = self._find_best_split(tokens, force_emit) - - if split_idx == 0: - if force_emit: - chunk = text - self.buffer = "" - return chunk - return None - - # Decode tokens up to split point - chunk_text = self.tokenizer.sp.decode(tokens[:split_idx]) - remaining_text = self.tokenizer.sp.decode(tokens[split_idx:]) - - self.buffer = remaining_text - return chunk_text.strip() - - def _find_best_split(self, tokens: list[int], force_emit: bool = False) -> int: - """Find the best token index to split at (sentence boundary near max_tokens).""" - # Find all sentence boundaries (position AFTER the punctuation) - boundaries = [] - prev_was_eos = False - - for i, token in enumerate(tokens): - if token in self.eos_tokens: - prev_was_eos = True - elif prev_was_eos: - boundaries.append(i) - prev_was_eos = False - - # Also consider end of tokens if it ends with punctuation - if tokens and tokens[-1] in self.eos_tokens: - boundaries.append(len(tokens)) - - if not boundaries: - # No sentence boundaries - split at max_tokens if we're over - if len(tokens) >= self.max_tokens: - return self.max_tokens - return len(tokens) if force_emit else 0 - - # Find boundary closest to max_tokens without going too far over - best_boundary = 0 - for boundary in boundaries: - if boundary <= self.max_tokens: - best_boundary = boundary - elif best_boundary == 0: - # First boundary is past max - use it anyway - best_boundary = boundary - break - else: - # We have a good boundary before max, stop - break - - return best_boundary - - def _ends_with_sentence_boundary(self, tokens: list[int]) -> bool: - """Check if token sequence ends with sentence-ending punctuation.""" - if not tokens: - return False - return tokens[-1] in self.eos_tokens - - @property - def buffered_text(self) -> str: - """Current buffered text (for debugging/monitoring).""" - return self.buffer - - @property - def buffered_token_count(self) -> int: - """Approximate token count in buffer.""" - if not self.buffer.strip(): - return 0 - return len(self.tokenizer(self.buffer).tokens[0].tolist()) - - -async def _run_tts_queue(state: TTSQueueState): - """Background task: consume text queue, produce audio chunks.""" - if tts_model is None: - state.status = "error" - state.error_message = "TTS model not loaded" - return - - model_state = tts_model._cached_get_state_for_audio_prompt(state.voice, truncate=True) - chunker = StreamingTextChunker(tts_model.flow_lm.conditioner.tokenizer) - chunk_index = 0 - char_offset = 0 - - try: - while True: - # Wait for text with timeout to detect stale queues - try: - text_item = await asyncio.wait_for( - state.text_queue.get(), - timeout=5.0 # Check every 5 seconds - ) - except asyncio.TimeoutError: - # Check if queue is stale (no activity for too long) - if time.time() - state.last_activity > QUEUE_TIMEOUT_SECONDS: - logger.warning(f"TTS queue {state.id} timeout after {QUEUE_TIMEOUT_SECONDS}s of inactivity") - state.status = "error" - state.error_message = f"Queue timeout: no activity for {QUEUE_TIMEOUT_SECONDS}s" - break - # Continue waiting - queue might still be active - continue - - if text_item is None: - # EOF - flush remaining text - remaining = chunker.flush() - for chunk_text in remaining: - await _process_tts_chunk(state, chunk_text, chunk_index, char_offset, model_state) - char_offset += len(chunk_text) - chunk_index += 1 - - state.status = "complete" - logger.info(f"TTS queue {state.id} complete: {chunk_index} chunks") - break - - # Feed text to chunker - ready_chunks = chunker.add_text(text_item) - - for chunk_text in ready_chunks: - await _process_tts_chunk(state, chunk_text, chunk_index, char_offset, model_state) - char_offset += len(chunk_text) - chunk_index += 1 - - except Exception as e: - logger.error(f"TTS queue {state.id} error: {e}") - state.status = "error" - state.error_message = str(e) - - -async def _process_tts_chunk( - state: TTSQueueState, - text: str, - chunk_index: int, - char_offset: int, - model_state: dict, -): - """Process a text chunk and add audio to state.""" - if tts_model is None: - return - - loop = asyncio.get_event_loop() - audio_bytes_list: list[bytes] = [] - total_samples = 0 - - def generate_sync(): - nonlocal total_samples - _, frames_after_eos = prepare_text_prompt(text) - frames_after_eos += 2 - - for audio_chunk in tts_model._generate_audio_stream_short_text( - model_state=model_state, - text_to_generate=text, - frames_after_eos=frames_after_eos, - copy_state=True, - ): - audio_int16 = (audio_chunk * 32767).to(torch.int16) - audio_bytes_list.append(audio_int16.cpu().numpy().tobytes()) - total_samples += len(audio_chunk) - - await loop.run_in_executor(None, generate_sync) - - combined_audio = b"".join(audio_bytes_list) - duration_ms = (total_samples / state.sample_rate) * 1000 - - chunk_data = AudioChunkData( - index=chunk_index, - audio_base64=base64.b64encode(combined_audio).decode(), - char_start=char_offset, - char_end=char_offset + len(text), - duration_ms=duration_ms, - ) - - async with state.lock: - state.audio_chunks.append(chunk_data) - - logger.debug(f"TTS queue {state.id}: chunk {chunk_index} ready ({duration_ms:.0f}ms)") - - -# ------------------------------------------------------ -# View Resource -# ------------------------------------------------------ - -# Embedded View HTML for standalone execution via `uv run ` -# Uses Babel standalone for in-browser JSX transpilation -# This is a copy of view.html - keep them in sync! -EMBEDDED_VIEW_HTML = """ - - - - - - Say View - - - - - -
- - -""" - - -def get_view_html() -> str: - """Get the View HTML, preferring built version from dist/.""" - # Prefer built version from dist/ (local development with npm run build) - dist_path = Path(__file__).parent / "dist" / "mcp-app.html" - if dist_path.exists(): - return dist_path.read_text() - # Fallback to embedded View (for `uv run ` or unbundled usage) - return EMBEDDED_VIEW_HTML - - -# IMPORTANT: all the external domains used by app must be listed -# in the meta.ui.csp.resourceDomains - otherwise they will be blocked by CSP policy -@mcp.resource( - VIEW_URI, - mime_type="text/html;profile=mcp-app", - meta={"ui": {"csp": {"resourceDomains": ["https://esm.sh", "https://unpkg.com"]}}}, -) -def view() -> str: - """View HTML resource with CSP metadata for external dependencies.""" - return get_view_html() - - -# ------------------------------------------------------ -# Startup -# ------------------------------------------------------ - -def load_tts_model(): - """Load the TTS model on startup.""" - global tts_model - logger.info("Loading TTS model...") - tts_model = TTSModel.load_model() - logger.info("TTS model loaded") - - -def create_app(): - """Create the ASGI app (for uvicorn reload mode).""" - load_tts_model() - # Pass host=HOST to disable DNS rebinding protection for non-localhost deployments - app = mcp.streamable_http_app(stateless_http=True, host=HOST) - app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], - ) - return app - - -if __name__ == "__main__": - logging.basicConfig(level=logging.INFO) - - if "--stdio" in sys.argv: - # Claude Desktop mode - load_tts_model() - mcp.run(transport="stdio") - elif "--reload" in sys.argv: - # Reload mode - pass app as string so uvicorn can reimport - print(f"Say Server listening on http://{HOST}:{PORT}/mcp (reload mode)") - uvicorn.run("server:create_app", host=HOST, port=PORT, reload=True, factory=True) - else: - # HTTP mode - app = create_app() - print(f"Say Server listening on http://{HOST}:{PORT}/mcp") - uvicorn.run(app, host=HOST, port=PORT) diff --git a/examples/scenario-modeler-server/README.md b/examples/scenario-modeler-server/README.md index 8b4c18e..c5252a7 100644 --- a/examples/scenario-modeler-server/README.md +++ b/examples/scenario-modeler-server/README.md @@ -4,51 +4,12 @@ A React-based demo MCP App that lets users adjust SaaS business parameters and s - - - + + +
Compare toBootstrapped growthVC rocketshipCompare toBootstrapped growthVC rocketship
-## MCP Client Configuration - -Add to your MCP client configuration (stdio transport): - -```json -{ - "mcpServers": { - "scenario-modeler": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-scenario-modeler", - "--stdio" - ] - } - } -} -``` - -### Local Development - -To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path): - -```json -{ - "mcpServers": { - "scenario-modeler": { - "command": "bash", - "args": [ - "-c", - "cd ~/code/ext-apps/examples/scenario-modeler-server && npm run build >&2 && node dist/index.js --stdio" - ] - } - } -} -``` - ## Features - **Interactive Parameters**: 5 sliders for Starting MRR, Growth Rate, Churn Rate, Gross Margin, and Fixed Costs diff --git a/examples/scenario-modeler-server/main.ts b/examples/scenario-modeler-server/main.ts index cc20e63..b7cc7d0 100644 --- a/examples/scenario-modeler-server/main.ts +++ b/examples/scenario-modeler-server/main.ts @@ -4,30 +4,41 @@ * 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"; +/** + * Shared utilities for running MCP servers with Streamable HTTP transport. + */ + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; import { createServer } from "./server.js"; +export interface ServerOptions { + port: number; + name?: string; +} + /** * Starts an MCP server with Streamable HTTP transport in stateless mode. * * @param createServer - Factory function that creates a new McpServer instance per request. + * @param options - Server configuration options. */ -export async function startStreamableHTTPServer( +export async function startServer( createServer: () => McpServer, + options: ServerOptions, ): Promise { - const port = parseInt(process.env.PORT ?? "3001", 10); + const { port, name = "MCP Server" } = options; 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({ + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); @@ -56,7 +67,7 @@ export async function startStreamableHTTPServer( console.error("Failed to start server:", err); process.exit(1); } - console.log(`MCP server listening on http://localhost:${port}/mcp`); + console.log(`${name} listening on http://localhost:${port}/mcp`); }); const shutdown = () => { @@ -68,22 +79,12 @@ export async function startStreamableHTTPServer( 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); + await createServer().connect(new StdioServerTransport()); } else { - await startStreamableHTTPServer(createServer); + const port = parseInt(process.env.PORT ?? "3106", 10); + await startServer(createServer, { port, name: "SaaS Scenario Modeler" }); } } diff --git a/examples/scenario-modeler-server/package.json b/examples/scenario-modeler-server/package.json index e6f37c0..97ba7a1 100644 --- a/examples/scenario-modeler-server/package.json +++ b/examples/scenario-modeler-server/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-scenario-modeler", - "version": "2.0.0", + "version": "0.4.1", "type": "module", "description": "Financial scenario modeling MCP App Server", "repository": { "type": "git", - "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", + "url": "https://github.com/modelcontextprotocol/ext-apps", "directory": "examples/scenario-modeler-server" }, "license": "MIT", @@ -16,31 +16,29 @@ "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", + "serve:http": "bun --watch server.ts", + "serve:stdio": "bun server.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\"", + "start:stdio": "cross-env NODE_ENV=development npm run build && 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" }, "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": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "chart.js": "^4.4.0", "cors": "^2.8.5", "express": "^5.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "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", diff --git a/examples/scenario-modeler-server/server.ts b/examples/scenario-modeler-server/server.ts index c5fa05d..b59851c 100644 --- a/examples/scenario-modeler-server/server.ts +++ b/examples/scenario-modeler-server/server.ts @@ -3,11 +3,11 @@ import { registerAppResource, registerAppTool, } from "@modelcontextprotocol/ext-apps/server"; -import { - McpServer, - type CallToolResult, - type ReadResourceResult, -} from "@modelcontextprotocol/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { + CallToolResult, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; @@ -305,8 +305,8 @@ export function createServer(): McpServer { title: "Get Scenario Data", description: "Returns SaaS scenario templates and optionally computes custom projections for given inputs", - inputSchema: GetScenarioDataInputSchema, - outputSchema: GetScenarioDataOutputSchema, + inputSchema: GetScenarioDataInputSchema.shape, + outputSchema: GetScenarioDataOutputSchema.shape, _meta: { ui: { resourceUri } }, }, async (args: { diff --git a/examples/scenario-modeler-server/tsconfig.json b/examples/scenario-modeler-server/tsconfig.json index 3ecbdad..fc3c210 100644 --- a/examples/scenario-modeler-server/tsconfig.json +++ b/examples/scenario-modeler-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/scenario-modeler-server/tsconfig.server.json b/examples/scenario-modeler-server/tsconfig.server.json index 27eb28f..05ddd8e 100644 --- a/examples/scenario-modeler-server/tsconfig.server.json +++ b/examples/scenario-modeler-server/tsconfig.server.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/shadertoy-server/README.md b/examples/shadertoy-server/README.md index e1390f6..5f965cb 100644 --- a/examples/shadertoy-server/README.md +++ b/examples/shadertoy-server/README.md @@ -4,57 +4,17 @@ A demo MCP App that renders [ShaderToy](https://www.shadertoy.com/)-compatible G - - - + + +
GradientKaleidoscopeKaleidoscopeGradientKaleidoscopeKaleidoscope
-## MCP Client Configuration - -Add to your MCP client configuration (stdio transport): - -```json -{ - "mcpServers": { - "shadertoy": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-shadertoy", - "--stdio" - ] - } - } -} -``` - -### Local Development - -To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path): - -```json -{ - "mcpServers": { - "shadertoy": { - "command": "bash", - "args": [ - "-c", - "cd ~/code/ext-apps/examples/shadertoy-server && npm run build >&2 && node dist/index.js --stdio" - ] - } - } -} -``` - ## Features - **Real-time Rendering**: Renders GLSL shaders using WebGL 2.0 - **ShaderToy Compatibility**: Uses the standard `mainImage(out vec4 fragColor, in vec2 fragCoord)` entry point - **Multi-pass Rendering**: Supports buffers A-D for feedback effects, blur chains, and simulations -- **Mouse & Touch Interaction**: Full iMouse support with click detection (works on mobile) - **Standard Uniforms**: iResolution, iTime, iTimeDelta, iFrame, iMouse, iDate, iChannel0-3 ## Running @@ -90,7 +50,10 @@ _Tool input:_ ```json { - "fragmentShader": "void mainImage(out vec4 fragColor, in vec2 fragCoord) {\n vec2 uv = fragCoord / iResolution.xy;\n fragColor = vec4(uv, 0.5 + 0.5*sin(iTime), 1.0);\n}" + "fragmentShader": "void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + fragColor = vec4(uv, 0.5 + 0.5*sin(iTime), 1.0); +}" } ``` @@ -119,24 +82,32 @@ _Tool input:_ ```json { - "fragmentShader": "void mainImage(out vec4 fragColor, in vec2 fragCoord) {\n vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;\n float segments = 6.0;\n float zoom = 1.0 + 0.3 * sin(iTime * 0.2);\n float angle = atan(uv.y, uv.x) + iTime * 0.3;\n float r = length(uv) * zoom;\n angle = mod(angle, 6.28 / segments);\n angle = abs(angle - 3.14 / segments);\n vec2 p = vec2(cos(angle), sin(angle)) * r;\n p += iTime * 0.1;\n float v = sin(p.x * 10.0) * sin(p.y * 10.0);\n v += sin(length(p) * 15.0 - iTime * 2.0);\n v += sin(p.x * 5.0 + p.y * 7.0 + iTime);\n vec3 col = 0.5 + 0.5 * cos(v * 2.0 + vec3(0.0, 2.0, 4.0) + iTime);\n fragColor = vec4(col, 1.0);\n}" + "fragmentShader": "void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y; + float segments = 6.0; + float zoom = 1.0 + 0.3 * sin(iTime * 0.2); + float angle = atan(uv.y, uv.x) + iTime * 0.3; + float r = length(uv) * zoom; + angle = mod(angle, 6.28 / segments); + angle = abs(angle - 3.14 / segments); + vec2 p = vec2(cos(angle), sin(angle)) * r; + p += iTime * 0.1; + float v = sin(p.x * 10.0) * sin(p.y * 10.0); + v += sin(length(p) * 15.0 - iTime * 2.0); + v += sin(p.x * 5.0 + p.y * 7.0 + iTime); + vec3 col = 0.5 + 0.5 * cos(v * 2.0 + vec3(0.0, 2.0, 4.0) + iTime); + fragColor = vec4(col, 1.0); +}" } ``` -**Interactive Julia Set** (click and drag to control the fractal's c parameter): +**Interactive Julia Set** (mouse controls the fractal's c parameter): ```glsl void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y * 2.5; - // Use mouse position if clicked, otherwise use animated default - vec2 c; - if (iMouse.z > 0.0) { - // Mouse is pressed - use mouse position - c = (iMouse.xy / iResolution.xy - 0.5) * 2.0; - } else { - // Not pressed - animate around an interesting region - c = vec2(-0.8 + 0.2 * sin(iTime * 0.5), 0.156 + 0.1 * cos(iTime * 0.7)); - } + vec2 mouse = (iMouse.xy / iResolution.xy - 0.5) * 2.0; + vec2 c = mouse; vec2 z = uv; float iter = 0.0; for (int i = 0; i < 100; i++) { @@ -155,33 +126,25 @@ _Tool input:_ ```json { - "fragmentShader": "void mainImage(out vec4 fragColor, in vec2 fragCoord) {\n vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y * 2.5;\n vec2 c;\n if (iMouse.z > 0.0) {\n c = (iMouse.xy / iResolution.xy - 0.5) * 2.0;\n } else {\n c = vec2(-0.8 + 0.2 * sin(iTime * 0.5), 0.156 + 0.1 * cos(iTime * 0.7));\n }\n vec2 z = uv;\n float iter = 0.0;\n for (int i = 0; i < 100; i++) {\n z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c;\n if (dot(z, z) > 4.0) break;\n iter++;\n }\n float t = iter / 100.0;\n vec3 col = 0.5 + 0.5 * cos(3.0 + t * 6.28 * 2.0 + vec3(0.0, 0.6, 1.0));\n if (iter == 100.0) col = vec3(0.0);\n fragColor = vec4(col, 1.0);\n}" + "fragmentShader": "void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y * 2.5; + vec2 mouse = (iMouse.xy / iResolution.xy - 0.5) * 2.0; + vec2 c = mouse; + vec2 z = uv; + float iter = 0.0; + for (int i = 0; i < 100; i++) { + z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c; + if (dot(z, z) > 4.0) break; + iter++; + } + float t = iter / 100.0; + vec3 col = 0.5 + 0.5 * cos(3.0 + t * 6.28 * 2.0 + vec3(0.0, 0.6, 1.0)); + if (iter == 100.0) col = vec3(0.0); + fragColor = vec4(col, 1.0); +}" } ``` -## Mouse & Touch Interaction - -The `iMouse` uniform provides interactive input, compatible with the official Shadertoy specification: - -| Component | When Button Down | After Release | Never Clicked | -| ----------- | ---------------------- | ---------------- | ------------- | -| `iMouse.xy` | Current position | Last position | `(0, 0)` | -| `iMouse.zw` | Click start (positive) | Negated (-x, -y) | `(0, 0)` | - -**Detecting button state:** - -```glsl -if (iMouse.z > 0.0) { - // Button/touch is currently held down -} else if (iMouse.z < 0.0) { - // Button was released (can use abs(iMouse.zw) for last click position) -} else { - // Never clicked - show default state or animate -} -``` - -Touch events are automatically supported for mobile devices. - ## Architecture ### Server (`server.ts`) diff --git a/examples/shadertoy-server/main.ts b/examples/shadertoy-server/main.ts index 7e388f0..717f5e6 100644 --- a/examples/shadertoy-server/main.ts +++ b/examples/shadertoy-server/main.ts @@ -4,30 +4,41 @@ * 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"; +/** + * Shared utilities for running MCP servers with Streamable HTTP transport. + */ + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; import { createServer } from "./server.js"; +export interface ServerOptions { + port: number; + name?: string; +} + /** * Starts an MCP server with Streamable HTTP transport in stateless mode. * * @param createServer - Factory function that creates a new McpServer instance per request. + * @param options - Server configuration options. */ -export async function startStreamableHTTPServer( +export async function startServer( createServer: () => McpServer, + options: ServerOptions, ): Promise { - const port = parseInt(process.env.PORT ?? "3001", 10); + const { port, name = "MCP Server" } = options; 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({ + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); @@ -56,7 +67,7 @@ export async function startStreamableHTTPServer( console.error("Failed to start server:", err); process.exit(1); } - console.log(`MCP server listening on http://localhost:${port}/mcp`); + console.log(`${name} listening on http://localhost:${port}/mcp`); }); const shutdown = () => { @@ -68,22 +79,12 @@ export async function startStreamableHTTPServer( 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); + await createServer().connect(new StdioServerTransport()); } else { - await startStreamableHTTPServer(createServer); + const port = parseInt(process.env.PORT ?? "3001", 10); + await startServer(createServer, { port, name: "ShaderToy Server" }); } } diff --git a/examples/shadertoy-server/mcp-app.html b/examples/shadertoy-server/mcp-app.html index 564597a..32c9d24 100644 --- a/examples/shadertoy-server/mcp-app.html +++ b/examples/shadertoy-server/mcp-app.html @@ -9,7 +9,6 @@
-

     
     
   );
 }
diff --git a/examples/threejs-server/tsconfig.json b/examples/threejs-server/tsconfig.json
index 3ecbdad..fc3c210 100644
--- a/examples/threejs-server/tsconfig.json
+++ b/examples/threejs-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/threejs-server/tsconfig.server.json b/examples/threejs-server/tsconfig.server.json
index 27eb28f..05ddd8e 100644
--- a/examples/threejs-server/tsconfig.server.json
+++ b/examples/threejs-server/tsconfig.server.json
@@ -1,6 +1,5 @@
 {
   "compilerOptions": {
-    "types": ["node"],
     "target": "ES2022",
     "lib": ["ES2022"],
     "module": "NodeNext",
diff --git a/examples/transcript-server/README.md b/examples/transcript-server/README.md
index 559ffad..dae2228 100644
--- a/examples/transcript-server/README.md
+++ b/examples/transcript-server/README.md
@@ -4,45 +4,6 @@
 
 An MCP App Server for live speech transcription using the Web Speech API.
 
-## MCP Client Configuration
-
-Add to your MCP client configuration (stdio transport):
-
-```json
-{
-  "mcpServers": {
-    "transcript": {
-      "command": "npx",
-      "args": [
-        "-y",
-        "--silent",
-        "--registry=https://registry.npmjs.org/",
-        "@modelcontextprotocol/server-transcript",
-        "--stdio"
-      ]
-    }
-  }
-}
-```
-
-### Local Development
-
-To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path):
-
-```json
-{
-  "mcpServers": {
-    "transcript": {
-      "command": "bash",
-      "args": [
-        "-c",
-        "cd ~/code/ext-apps/examples/transcript-server && npm run build >&2 && node dist/index.js --stdio"
-      ]
-    }
-  }
-}
-```
-
 ## Features
 
 - **Live Transcription**: Real-time speech-to-text using browser's Web Speech API
@@ -56,7 +17,7 @@ To test local modifications, use this configuration (replace `~/code/ext-apps` w
 
 ### Prerequisites
 
-- Node.js 20+
+- Node.js 18+
 - Chrome, Edge, or Safari (Web Speech API support)
 
 ### Installation
diff --git a/examples/transcript-server/grid-cell.png b/examples/transcript-server/grid-cell.png
index 60ea7ea..6895b33 100644
Binary files a/examples/transcript-server/grid-cell.png and b/examples/transcript-server/grid-cell.png differ
diff --git a/examples/transcript-server/main.ts b/examples/transcript-server/main.ts
index e86ad7c..2a92704 100644
--- a/examples/transcript-server/main.ts
+++ b/examples/transcript-server/main.ts
@@ -4,28 +4,38 @@
  * 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";
+/**
+ * Shared utilities for running MCP servers with Streamable HTTP transport.
+ */
+
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
+import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
 import cors from "cors";
 import type { Request, Response } from "express";
 import { createServer } from "./server.js";
 
+export interface ServerOptions {
+  port: number;
+  name?: string;
+}
+
 /**
  * Starts an MCP server with Streamable HTTP transport in stateless mode.
  */
-export async function startStreamableHTTPServer(
+export async function startServer(
   createServer: () => McpServer,
+  options: ServerOptions,
 ): Promise {
-  const port = parseInt(process.env.PORT ?? "3001", 10);
+  const { port, name = "MCP Server" } = options;
 
   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({
+    const transport = new StreamableHTTPServerTransport({
       sessionIdGenerator: undefined,
     });
 
@@ -54,7 +64,7 @@ export async function startStreamableHTTPServer(
       console.error("Failed to start server:", err);
       process.exit(1);
     }
-    console.log(`MCP server listening on http://localhost:${port}/mcp`);
+    console.log(`${name} listening on http://localhost:${port}/mcp`);
   });
 
   const shutdown = () => {
@@ -66,22 +76,12 @@ export async function startStreamableHTTPServer(
   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);
+    await createServer().connect(new StdioServerTransport());
   } else {
-    await startStreamableHTTPServer(createServer);
+    const port = parseInt(process.env.PORT ?? "3109", 10);
+    await startServer(createServer, { port, name: "Transcript Server" });
   }
 }
 
diff --git a/examples/transcript-server/package.json b/examples/transcript-server/package.json
index 46a8f98..4aeaab8 100644
--- a/examples/transcript-server/package.json
+++ b/examples/transcript-server/package.json
@@ -1,11 +1,11 @@
 {
   "name": "@modelcontextprotocol/server-transcript",
-  "version": "2.0.0",
+  "version": "0.4.1",
   "type": "module",
   "description": "MCP App Server for live speech transcription",
   "repository": {
     "type": "git",
-    "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
+    "url": "https://github.com/modelcontextprotocol/ext-apps",
     "directory": "examples/transcript-server"
   },
   "license": "MIT",
@@ -17,26 +17,22 @@
     "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\"",
+    "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",
+    "@modelcontextprotocol/ext-apps": "^0.4.1",
+    "@modelcontextprotocol/sdk": "^1.24.0",
     "cors": "^2.8.5",
     "express": "^5.1.0",
-    "zod": "^4.2.0"
+    "zod": "^3.23.0"
   },
   "devDependencies": {
     "@types/cors": "^2.8.19",
     "@types/dom-speech-recognition": "^0.0.7",
     "@types/express": "^5.0.0",
-    "@types/node": "22.10.0",
+    "@types/node": "^22.0.0",
     "concurrently": "^9.2.1",
     "cross-env": "^10.1.0",
     "typescript": "^5.9.3",
diff --git a/examples/transcript-server/server.ts b/examples/transcript-server/server.ts
index 863eb29..035c8b6 100644
--- a/examples/transcript-server/server.ts
+++ b/examples/transcript-server/server.ts
@@ -1,21 +1,21 @@
-import {
-  McpServer,
-  type CallToolResult,
-  type ReadResourceResult,
-} from "@modelcontextprotocol/server";
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import type {
+  CallToolResult,
+  ReadResourceResult,
+} from "@modelcontextprotocol/sdk/types.js";
 import fs from "node:fs/promises";
 import path from "node:path";
 import {
   registerAppTool,
   registerAppResource,
   RESOURCE_MIME_TYPE,
+  RESOURCE_URI_META_KEY,
 } 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;
-const resourceUri = "ui://transcript/mcp-app.html";
+const RESOURCE_URI = "ui://transcript/mcp-app.html";
 
 /**
  * Creates a new MCP server instance with tools and resources registered.
@@ -34,8 +34,8 @@ export function createServer(): McpServer {
       title: "Transcribe Speech",
       description:
         "Opens a live speech transcription interface using the Web Speech API.",
-      inputSchema: z.object({}),
-      _meta: { ui: { resourceUri } },
+      inputSchema: {},
+      _meta: { [RESOURCE_URI_META_KEY]: RESOURCE_URI },
     },
     async (): Promise => {
       return {
@@ -55,8 +55,8 @@ export function createServer(): McpServer {
   // Register the UI resource
   registerAppResource(
     server,
-    resourceUri,
-    resourceUri,
+    RESOURCE_URI,
+    RESOURCE_URI,
     { mimeType: RESOURCE_MIME_TYPE, description: "Transcript UI" },
     async (): Promise => {
       const html = await fs.readFile(
@@ -67,7 +67,7 @@ export function createServer(): McpServer {
       return {
         contents: [
           {
-            uri: resourceUri,
+            uri: RESOURCE_URI,
             mimeType: RESOURCE_MIME_TYPE,
             text: html,
             _meta: {
diff --git a/examples/transcript-server/src/mcp-app.ts b/examples/transcript-server/src/mcp-app.ts
index 7e3787e..61adc7b 100644
--- a/examples/transcript-server/src/mcp-app.ts
+++ b/examples/transcript-server/src/mcp-app.ts
@@ -68,7 +68,7 @@ app.onteardown = async () => {
 
 app.onerror = log.error;
 
-function handleHostContextChanged(ctx: McpUiHostContext) {
+app.onhostcontextchanged = (ctx: McpUiHostContext) => {
   if (ctx.safeAreaInsets) {
     mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
     mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`;
@@ -78,9 +78,7 @@ function handleHostContextChanged(ctx: McpUiHostContext) {
   if (ctx.theme) {
     applyDocumentTheme(ctx.theme);
   }
-}
-
-app.onhostcontextchanged = handleHostContextChanged;
+};
 
 // ============================================================================
 // Audio Capture
@@ -548,6 +546,6 @@ app.connect().then(() => {
   log.info("Connected to host");
   const ctx = app.getHostContext();
   if (ctx) {
-    handleHostContextChanged(ctx);
+    app.onhostcontextchanged?.(ctx);
   }
 });
diff --git a/examples/transcript-server/tsconfig.json b/examples/transcript-server/tsconfig.json
index 00e4801..535267b 100644
--- a/examples/transcript-server/tsconfig.json
+++ b/examples/transcript-server/tsconfig.json
@@ -1,6 +1,5 @@
 {
   "compilerOptions": {
-    "types": ["node", "vite/client", "dom-speech-recognition"],
     "target": "ESNext",
     "lib": ["ESNext", "DOM", "DOM.Iterable"],
     "module": "ESNext",
diff --git a/examples/transcript-server/tsconfig.server.json b/examples/transcript-server/tsconfig.server.json
index 27eb28f..05ddd8e 100644
--- a/examples/transcript-server/tsconfig.server.json
+++ b/examples/transcript-server/tsconfig.server.json
@@ -1,6 +1,5 @@
 {
   "compilerOptions": {
-    "types": ["node"],
     "target": "ES2022",
     "lib": ["ES2022"],
     "module": "NodeNext",
diff --git a/examples/video-resource-server/README.md b/examples/video-resource-server/README.md
index 0d385f5..252e6bb 100644
--- a/examples/video-resource-server/README.md
+++ b/examples/video-resource-server/README.md
@@ -4,45 +4,6 @@
 
 Demonstrates serving binary content (video) via MCP resources using the base64 blob pattern.
 
-## MCP Client Configuration
-
-Add to your MCP client configuration (stdio transport):
-
-```json
-{
-  "mcpServers": {
-    "video-resource": {
-      "command": "npx",
-      "args": [
-        "-y",
-        "--silent",
-        "--registry=https://registry.npmjs.org/",
-        "@modelcontextprotocol/server-video-resource",
-        "--stdio"
-      ]
-    }
-  }
-}
-```
-
-### Local Development
-
-To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path):
-
-```json
-{
-  "mcpServers": {
-    "video-resource": {
-      "command": "bash",
-      "args": [
-        "-c",
-        "cd ~/code/ext-apps/examples/video-resource-server && npm run build >&2 && node dist/index.js --stdio"
-      ]
-    }
-  }
-}
-```
-
 ## Quick Start
 
 ```bash
@@ -58,6 +19,6 @@ npm run dev
 ## How It Works
 
 1. The `play_video` tool returns a `videoUri` pointing to an MCP resource
-2. The view fetches the resource via `resources/read`
+2. The widget fetches the resource via `resources/read`
 3. The server fetches the video from CDN and returns it as a base64 blob
-4. The view decodes the blob and plays it in a `
diff --git a/examples/video-resource-server/package.json b/examples/video-resource-server/package.json index 4a7a61f..9361768 100644 --- a/examples/video-resource-server/package.json +++ b/examples/video-resource-server/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-video-resource", - "version": "2.0.0", + "version": "0.4.1", "type": "module", "description": "MCP App Server demonstrating video resources served as base64 blobs", "repository": { "type": "git", - "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", + "url": "https://github.com/modelcontextprotocol/ext-apps", "directory": "examples/video-resource-server" }, "license": "MIT", @@ -17,26 +17,21 @@ "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\"", + "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", + "@modelcontextprotocol/ext-apps": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "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", "typescript": "^5.9.3", diff --git a/examples/video-resource-server/server.ts b/examples/video-resource-server/server.ts index 8ae6240..2397665 100644 --- a/examples/video-resource-server/server.ts +++ b/examples/video-resource-server/server.ts @@ -12,9 +12,11 @@ import { import { McpServer, ResourceTemplate, - type CallToolResult, - type ReadResourceResult, -} from "@modelcontextprotocol/server"; +} from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { + CallToolResult, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; @@ -67,16 +69,7 @@ export function createServer(): McpServer { server.registerResource( "video", - new ResourceTemplate("videos://{id}", { - list: async () => ({ - resources: Object.entries(VIDEO_LIBRARY).map(([id, video]) => ({ - uri: `videos://${id}`, - name: `video-${id}`, - description: `Video: ${video.description}`, - mimeType: "video/mp4", - })), - }), - }), + new ResourceTemplate("videos://{id}", { list: undefined }), { description: "Video served via MCP resource (base64 blob)", mimeType: "video/mp4", @@ -133,14 +126,14 @@ Available videos: ${Object.entries(VIDEO_LIBRARY) .map(([id, v]) => `- ${id}: ${v.description}`) .join("\n")}`, - inputSchema: z.object({ + inputSchema: { videoId: z .enum(Object.keys(VIDEO_LIBRARY) as [string, ...string[]]) .default("bunny-1mb") .describe( `Video ID to play. Available: ${Object.keys(VIDEO_LIBRARY).join(", ")}`, ), - }), + }, outputSchema: z.object({ videoUri: z.string(), description: z.string(), diff --git a/examples/video-resource-server/src/global.css b/examples/video-resource-server/src/global.css index d94a599..b53cf5e 100644 --- a/examples/video-resource-server/src/global.css +++ b/examples/video-resource-server/src/global.css @@ -1,94 +1,10 @@ -: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; margin: 0; padding: 0; } - -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/video-resource-server/src/mcp-app.css b/examples/video-resource-server/src/mcp-app.css index 3413974..3312553 100644 --- a/examples/video-resource-server/src/mcp-app.css +++ b/examples/video-resource-server/src/mcp-app.css @@ -8,16 +8,16 @@ flex-direction: column; align-items: center; justify-content: center; - padding: var(--spacing-lg); - gap: var(--spacing-md); - color: var(--color-text-info); + padding: 2rem; + gap: 1rem; + color: #6b7280; } .spinner { width: 48px; height: 48px; - border: 4px solid color-mix(in srgb, var(--color-text-primary) 20%, transparent); - border-top-color: var(--color-accent); + border: 4px solid #e5e7eb; + border-top-color: #3b82f6; border-radius: 50%; animation: spin 1s linear infinite; } @@ -27,13 +27,13 @@ } .error { - padding: var(--spacing-md); + padding: 1rem; color: #dc2626; } .error-title { - font-weight: var(--font-weight-bold); - margin: 0 0 var(--spacing-sm) 0; + font-weight: 600; + margin: 0 0 0.5rem 0; } .error p { @@ -43,75 +43,12 @@ .player video { width: 100%; max-height: 480px; - border-radius: var(--border-radius-md); + border-radius: 8px; background-color: #000; } .video-info { - margin-top: var(--spacing-sm); + margin-top: 0.5rem; font-size: 0.875rem; - color: var(--color-text-info); -} - -.picker-container { - padding: var(--spacing-md); -} - -.picker-container h3 { - margin: 0 0 var(--spacing-md) 0; - font-size: var(--font-heading-lg-size); - font-weight: var(--font-weight-bold); -} - -.video-picker { - width: 100%; - padding: var(--spacing-sm); - font-size: var(--font-text-md-size); - border: var(--border-width-regular) solid color-mix(in srgb, var(--color-text-primary) 20%, transparent); - border-radius: var(--border-radius-md); - margin-bottom: var(--spacing-md); - background-color: var(--color-background-primary); - color: var(--color-text-primary); - font-family: var(--font-sans); -} - -@media (prefers-color-scheme: dark) { - .video-picker option { - background-color: var(--color-background-info); - color: var(--color-text-primary); - } -} - -.load-btn, -.change-btn { - padding: var(--spacing-sm) var(--spacing-md); - font-size: var(--font-text-md-size); - font-weight: var(--font-weight-bold); - color: var(--color-text-on-accent); - background-color: var(--color-accent); - border: none; - border-radius: var(--border-radius-md); - cursor: pointer; - transition: background-color 0.2s; -} - -.load-btn:hover:not(:disabled), -.change-btn:hover { - background-color: color-mix(in srgb, var(--color-accent) 85%, var(--color-background-inverse)); -} - -.load-btn:focus-visible, -.change-btn:focus-visible { - outline: calc(var(--border-width-regular) * 2) solid var(--color-ring-primary); - outline-offset: var(--border-width-regular); -} - -.load-btn:disabled { - background-color: color-mix(in srgb, var(--color-text-primary) 40%, transparent); - cursor: not-allowed; - opacity: 0.6; -} - -.change-btn { - margin-top: var(--spacing-md); + color: #6b7280; } diff --git a/examples/video-resource-server/src/mcp-app.ts b/examples/video-resource-server/src/mcp-app.ts index 2a3f8f9..bc7688a 100644 --- a/examples/video-resource-server/src/mcp-app.ts +++ b/examples/video-resource-server/src/mcp-app.ts @@ -5,7 +5,8 @@ * The video is served as a base64 blob and converted to a data URI for playback. */ import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; -import type { CallToolResult } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { ReadResourceResultSchema } from "@modelcontextprotocol/sdk/types.js"; import "./global.css"; import "./mcp-app.css"; @@ -18,19 +19,9 @@ const loadingEl = document.getElementById("loading")!; const loadingTextEl = document.getElementById("loading-text")!; const errorEl = document.getElementById("error")!; const errorMessageEl = document.getElementById("error-message")!; -const pickerContainerEl = document.getElementById("video-picker-container")!; -const videoPickerEl = document.getElementById( - "video-picker", -) as HTMLSelectElement; -const loadVideoBtn = document.getElementById( - "load-video-btn", -) as HTMLButtonElement; const playerEl = document.getElementById("player")!; const videoEl = document.getElementById("video") as HTMLVideoElement; const videoInfoEl = document.getElementById("video-info")!; -const changeVideoBtn = document.getElementById( - "change-video-btn", -) as HTMLButtonElement; // ============================================================================= // UI State Helpers @@ -50,7 +41,6 @@ function showLoading(text: string) { loadingTextEl.textContent = text; loadingEl.style.display = "flex"; errorEl.style.display = "none"; - pickerContainerEl.style.display = "none"; playerEl.style.display = "none"; } @@ -58,36 +48,15 @@ function showError(message: string) { errorMessageEl.textContent = message; loadingEl.style.display = "none"; errorEl.style.display = "block"; - pickerContainerEl.style.display = "none"; playerEl.style.display = "none"; } -function showPicker(keepPlayer = true) { - loadingEl.style.display = "none"; - errorEl.style.display = "none"; - pickerContainerEl.style.display = "block"; - changeVideoBtn.style.display = "none"; - if (!keepPlayer || !videoEl.src || videoEl.src === window.location.href) { - playerEl.style.display = "none"; - } -} - -let currentObjectUrl: string | null = null; - -function showPlayer(objectUrl: string, info: string) { - // Revoke previous Object URL to free memory - if (currentObjectUrl) { - URL.revokeObjectURL(currentObjectUrl); - } - currentObjectUrl = objectUrl; - - videoEl.src = objectUrl; +function showPlayer(dataUri: string, info: string) { + videoEl.src = dataUri; videoInfoEl.textContent = info; loadingEl.style.display = "none"; errorEl.style.display = "none"; - pickerContainerEl.style.display = "none"; playerEl.style.display = "block"; - changeVideoBtn.style.display = "inline-block"; } // ============================================================================= @@ -96,81 +65,8 @@ function showPlayer(objectUrl: string, info: string) { const app = new App({ name: "Video Resource Player", version: "1.0.0" }); -async function fetchAndPlayVideo(uri: string, label: string) { - console.info("Requesting resource:", uri); - - const resourceResult = await app.readServerResource({ uri }); - - const content = resourceResult.contents[0]; - if (!content || !("blob" in content)) { - throw new Error("Resource response did not contain blob data"); - } - - console.info("Resource received, blob size:", content.blob.length); - - const mimeType = content.mimeType || "video/mp4"; - const binary = Uint8Array.from(atob(content.blob), (c) => c.charCodeAt(0)); - const objectUrl = URL.createObjectURL(new Blob([binary], { type: mimeType })); - - showPlayer(objectUrl, label); -} - -async function discoverVideos() { - // Don't interrupt playback with loading state - just show picker if video is loaded - const hasVideo = Boolean(videoEl.src && videoEl.src !== window.location.href); - if (!hasVideo) { - showLoading("Discovering available videos..."); - } - - try { - const resourceList = await app.listServerResources(); - - const videoResources = resourceList.resources.filter((r) => - r.uri.startsWith("videos://"), - ); - - if (videoResources.length === 0) { - showError("No videos available. Server has no video resources."); - return; - } - - videoPickerEl.innerHTML = ''; - - videoResources.forEach((resource) => { - const option = document.createElement("option"); - option.value = resource.uri; - option.text = resource.name || resource.description || resource.uri; - videoPickerEl.appendChild(option); - }); - - showPicker(hasVideo); - } catch (err) { - console.error("Error discovering videos:", err); - showError(err instanceof Error ? err.message : String(err)); - } -} - -async function loadSelectedVideo() { - const selectedUri = videoPickerEl.value; - if (!selectedUri) { - return; - } - - showLoading("Loading video..."); - - try { - const selectedText = - videoPickerEl.options[videoPickerEl.selectedIndex].text; - await fetchAndPlayVideo( - selectedUri, - `${selectedText} (loaded via MCP resource)`, - ); - } catch (err) { - console.error("Error loading video:", err); - showError(err instanceof Error ? err.message : String(err)); - } -} - +// Handle tool result - Requests a resource via resources/read and converts the +// base64 blob to a data URI for use in the browser. app.ontoolresult = async (result) => { console.info("Received tool result:", result); @@ -186,10 +82,28 @@ app.ontoolresult = async (result) => { showLoading("Fetching video from MCP resource..."); try { - await fetchAndPlayVideo( - videoUri, - `Loaded via MCP resource (${description})`, + console.info("Requesting resource:", videoUri); + + const resourceResult = await app.request( + { method: "resources/read", params: { uri: videoUri } }, + ReadResourceResultSchema, ); + + const content = resourceResult.contents[0]; + if (!content || !("blob" in content)) { + throw new Error("Resource response did not contain blob data"); + } + + console.info("Resource received, blob size:", content.blob.length); + + showLoading("Converting to data URI..."); + + const mimeType = content.mimeType || "video/mp4"; + const dataUri = `data:${mimeType};base64,${content.blob}`; + + console.info("Data URI created, length:", dataUri.length); + + showPlayer(dataUri, `Loaded via MCP resource (${description})`); } catch (err) { console.error("Error fetching resource:", err); showError(err instanceof Error ? err.message : String(err)); @@ -201,16 +115,6 @@ app.onerror = (err) => { showError(err instanceof Error ? err.message : String(err)); }; -videoPickerEl.addEventListener("change", () => { - loadVideoBtn.disabled = !videoPickerEl.value; -}); - -loadVideoBtn.addEventListener("click", loadSelectedVideo); - -changeVideoBtn.addEventListener("click", () => { - discoverVideos(); -}); - function handleHostContextChanged(ctx: McpUiHostContext) { if (ctx.safeAreaInsets) { mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`; diff --git a/examples/video-resource-server/tsconfig.json b/examples/video-resource-server/tsconfig.json index b028ff5..535267b 100644 --- a/examples/video-resource-server/tsconfig.json +++ b/examples/video-resource-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/video-resource-server/tsconfig.server.json b/examples/video-resource-server/tsconfig.server.json index 27eb28f..05ddd8e 100644 --- a/examples/video-resource-server/tsconfig.server.json +++ b/examples/video-resource-server/tsconfig.server.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/wiki-explorer-server/README.md b/examples/wiki-explorer-server/README.md index cc91b23..bd827e3 100644 --- a/examples/wiki-explorer-server/README.md +++ b/examples/wiki-explorer-server/README.md @@ -4,51 +4,12 @@ Visualizes Wikipedia link graphs using a force-directed layout. Explore how Wiki - - - + + +
ZoomedPop-upExpanded graphZoomedPop-upExpanded graph
-## MCP Client Configuration - -Add to your MCP client configuration (stdio transport): - -```json -{ - "mcpServers": { - "wiki-explorer": { - "command": "npx", - "args": [ - "-y", - "--silent", - "--registry=https://registry.npmjs.org/", - "@modelcontextprotocol/server-wiki-explorer", - "--stdio" - ] - } - } -} -``` - -### Local Development - -To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path): - -```json -{ - "mcpServers": { - "wiki-explorer": { - "command": "bash", - "args": [ - "-c", - "cd ~/code/ext-apps/examples/wiki-explorer-server && npm run build >&2 && node dist/index.js --stdio" - ] - } - } -} -``` - ## Features - **Force-directed graph visualization**: Interactive graph powered by [`force-graph`](https://github.com/vasturiano/force-graph) diff --git a/examples/wiki-explorer-server/main.ts b/examples/wiki-explorer-server/main.ts index bfeeffb..d83ac42 100644 --- a/examples/wiki-explorer-server/main.ts +++ b/examples/wiki-explorer-server/main.ts @@ -4,30 +4,41 @@ * 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"; +/** + * Shared utilities for running MCP servers with Streamable HTTP transport. + */ + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; import { createServer } from "./server.js"; +export interface ServerOptions { + port: number; + name?: string; +} + /** * Starts an MCP server with Streamable HTTP transport in stateless mode. * * @param createServer - Factory function that creates a new McpServer instance per request. + * @param options - Server configuration options. */ -export async function startStreamableHTTPServer( +export async function startServer( createServer: () => McpServer, + options: ServerOptions, ): Promise { - const port = parseInt(process.env.PORT ?? "3001", 10); + const { port, name = "MCP Server" } = options; 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({ + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); @@ -56,7 +67,7 @@ export async function startStreamableHTTPServer( console.error("Failed to start server:", err); process.exit(1); } - console.log(`MCP server listening on http://localhost:${port}/mcp`); + console.log(`${name} listening on http://localhost:${port}/mcp`); }); const shutdown = () => { @@ -68,22 +79,12 @@ export async function startStreamableHTTPServer( 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); + await createServer().connect(new StdioServerTransport()); } else { - await startStreamableHTTPServer(createServer); + const port = parseInt(process.env.PORT ?? "3109", 10); + await startServer(createServer, { port, name: "Wiki Explorer" }); } } diff --git a/examples/wiki-explorer-server/package.json b/examples/wiki-explorer-server/package.json index 52304cf..6b9b68b 100644 --- a/examples/wiki-explorer-server/package.json +++ b/examples/wiki-explorer-server/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-wiki-explorer", - "version": "2.0.0", + "version": "0.4.1", "type": "module", "description": "Wikipedia link explorer MCP App Server with graph visualization", "repository": { "type": "git", - "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", + "url": "https://github.com/modelcontextprotocol/ext-apps", "directory": "examples/wiki-explorer-server" }, "license": "MIT", @@ -16,30 +16,27 @@ "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", + "serve:http": "bun --watch server.ts", + "serve:stdio": "bun server.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\"", + "start:stdio": "cross-env NODE_ENV=development npm run build && 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" }, "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", + "@modelcontextprotocol/ext-apps": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cheerio": "^1.0.0", "cors": "^2.8.5", "express": "^5.1.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "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", "force-graph": "^1.49.0", diff --git a/examples/wiki-explorer-server/server.ts b/examples/wiki-explorer-server/server.ts index 8bc4eb7..fe2e89b 100644 --- a/examples/wiki-explorer-server/server.ts +++ b/examples/wiki-explorer-server/server.ts @@ -3,11 +3,11 @@ import { registerAppResource, registerAppTool, } from "@modelcontextprotocol/ext-apps/server"; -import { - McpServer, - type CallToolResult, - type ReadResourceResult, -} from "@modelcontextprotocol/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { + CallToolResult, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; import * as cheerio from "cheerio"; import fs from "node:fs/promises"; import path from "node:path"; @@ -86,7 +86,7 @@ export function createServer(): McpServer { { title: "Get First-Degree Links", description: - "Returns all Wikipedia pages that the given page links to directly. The widget is interactive and exposes tools for exploring the graph (expanding nodes to see their links), searching for articles, and querying visible nodes.", + "Returns all Wikipedia pages that the given page links to directly.", inputSchema: z.object({ url: z .string() diff --git a/examples/wiki-explorer-server/src/mcp-app.css b/examples/wiki-explorer-server/src/mcp-app.css index ea13ac2..8f6f024 100644 --- a/examples/wiki-explorer-server/src/mcp-app.css +++ b/examples/wiki-explorer-server/src/mcp-app.css @@ -1,7 +1,6 @@ #graph { width: 100%; height: 100vh; - touch-action: none; } #popup { diff --git a/examples/wiki-explorer-server/src/mcp-app.ts b/examples/wiki-explorer-server/src/mcp-app.ts index 9833d9c..7d95cb8 100644 --- a/examples/wiki-explorer-server/src/mcp-app.ts +++ b/examples/wiki-explorer-server/src/mcp-app.ts @@ -2,8 +2,7 @@ * Wiki Explorer - Force-directed graph visualization of Wikipedia link networks */ import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; -import type { CallToolResult } from "@modelcontextprotocol/client"; -import { z } from "zod"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { forceCenter, forceCollide, @@ -112,18 +111,6 @@ const graph = new ForceGraph(container) }) .graphData(graphData); -// Prevent touch events from propagating to the parent scroll view. -// force-graph uses pointer events, which don't suppress native scroll gesture -// recognition on touch devices. -const graphCanvas = container.querySelector("canvas"); -if (graphCanvas) { - for (const eventName of ["touchstart", "touchmove"] as const) { - graphCanvas.addEventListener(eventName, (e) => e.preventDefault(), { - passive: false, - }); - } -} - // Handle window resize function handleResize() { const { width, height } = container.getBoundingClientRect(); @@ -398,346 +385,6 @@ function handleHostContextChanged(ctx: McpUiHostContext) { app.onhostcontextchanged = handleHostContextChanged; -// ============================================================================= -// Widget Interaction Tools -// ============================================================================= - -// Tool: Search for a Wikipedia article and navigate to it -app.registerTool( - "search-article", - { - title: "Search Article", - description: - "Search for a Wikipedia article and add it to the graph as the new starting point", - inputSchema: z.object({ - query: z.string().describe("Search query for Wikipedia article"), - }), - }, - async (args) => { - const { query } = args as { query: string }; - - // Construct Wikipedia search URL that redirects to the article - const searchUrl = `https://en.wikipedia.org/wiki/Special:Search?go=Go&search=${encodeURIComponent(query)}`; - - // Use the server tool to fetch the article - const result = await app.callServerTool({ - name: "get-first-degree-links", - arguments: { url: searchUrl }, - }); - - const response = result.structuredContent as unknown as ToolResponse; - if (response && response.page) { - // Clear existing graph and start fresh with this article - graphData.nodes = []; - graphData.links = []; - initialUrl = response.page.url; - addNode(response.page.url, response.page.title, "default", { - x: 0, - y: 0, - }); - graph.warmupTicks(100); - handleToolResultData(result); - graph.centerAt(0, 0, 500); - - return { - content: [ - { - type: "text" as const, - text: `Navigated to article: ${response.page.title}`, - }, - ], - structuredContent: { - success: true, - article: response.page, - linksFound: response.links?.length ?? 0, - }, - }; - } - - return { - content: [ - { type: "text" as const, text: `Could not find article for: ${query}` }, - ], - structuredContent: { - success: false, - error: "Article not found", - }, - }; - }, -); - -// Tool: Get information about the currently displayed article -app.registerTool( - "get-current-article", - { - title: "Get Current Article", - description: - "Get information about the currently selected or initial article in the graph", - }, - async () => { - const currentUrl = selectedNodeUrl || initialUrl; - - if (!currentUrl) { - return { - content: [ - { type: "text" as const, text: "No article is currently selected" }, - ], - structuredContent: { - hasSelection: false, - article: null, - }, - }; - } - - const node = graphData.nodes.find((n) => n.url === currentUrl); - - if (!node) { - return { - content: [ - { - type: "text" as const, - text: "Selected article not found in graph", - }, - ], - structuredContent: { - hasSelection: false, - article: null, - }, - }; - } - - return { - content: [ - { - type: "text" as const, - text: `Current article: ${node.title}\nURL: ${node.url}\nState: ${node.state}`, - }, - ], - structuredContent: { - hasSelection: true, - article: { - url: node.url, - title: node.title, - state: node.state, - isExpanded: node.state === "expanded", - hasError: node.state === "error", - errorMessage: node.errorMessage, - }, - }, - }; - }, -); - -// Tool: Highlight a specific node in the graph -app.registerTool( - "highlight-node", - { - title: "Highlight Node", - description: - "Highlight and center on a specific node in the graph by title or URL", - inputSchema: z.object({ - identifier: z - .string() - .describe("The title or URL of the node to highlight"), - }), - }, - async (args) => { - const { identifier } = args as { identifier: string }; - const lowerIdentifier = identifier.toLowerCase(); - - // Find node by title (case-insensitive partial match) or exact URL - const node = graphData.nodes.find( - (n) => - n.url === identifier || n.title.toLowerCase().includes(lowerIdentifier), - ); - - if (!node) { - return { - content: [ - { type: "text" as const, text: `Node not found: ${identifier}` }, - ], - structuredContent: { - success: false, - error: "Node not found in graph", - availableNodes: graphData.nodes.map((n) => n.title), - }, - }; - } - - // Center on the node and select it - selectedNodeUrl = node.url; - if (node.x !== undefined && node.y !== undefined) { - graph.centerAt(node.x, node.y, 500); - graph.zoom(2, 500); - } - - return { - content: [ - { type: "text" as const, text: `Highlighted node: ${node.title}` }, - ], - structuredContent: { - success: true, - node: { - url: node.url, - title: node.title, - state: node.state, - }, - }, - }; - }, -); - -// Tool: Expand a node to show its linked pages -app.registerTool( - "expand-node", - { - title: "Expand Node", - description: - "Expand a node to fetch and display all Wikipedia pages it links to. This is the core way to explore the graph.", - inputSchema: z.object({ - identifier: z.string().describe("The title or URL of the node to expand"), - }), - }, - async (args) => { - const { identifier } = args as { identifier: string }; - const lowerIdentifier = identifier.toLowerCase(); - - // Find node by title (case-insensitive partial match) or exact URL - const node = graphData.nodes.find( - (n) => - n.url === identifier || n.title.toLowerCase().includes(lowerIdentifier), - ); - - if (!node) { - return { - content: [ - { type: "text" as const, text: `Node not found: ${identifier}` }, - ], - structuredContent: { - success: false, - error: "Node not found in graph", - availableNodes: graphData.nodes.map((n) => n.title), - }, - }; - } - - if (node.state === "expanded") { - return { - content: [ - { - type: "text" as const, - text: `Node "${node.title}" is already expanded`, - }, - ], - structuredContent: { - success: true, - alreadyExpanded: true, - node: { url: node.url, title: node.title }, - }, - }; - } - - if (node.state === "error") { - return { - content: [ - { - type: "text" as const, - text: `Node "${node.title}" has an error: ${node.errorMessage}`, - }, - ], - structuredContent: { - success: false, - error: node.errorMessage, - }, - }; - } - - try { - // Fetch the linked pages using the server tool - const result = await app.callServerTool({ - name: "get-first-degree-links", - arguments: { url: node.url }, - }); - - graph.warmupTicks(0); - handleToolResultData(result); - - const response = result.structuredContent as unknown as ToolResponse; - const linksAdded = response?.links?.length ?? 0; - - // Center on the expanded node - if (node.x !== undefined && node.y !== undefined) { - graph.centerAt(node.x, node.y, 500); - } - - return { - content: [ - { - type: "text" as const, - text: `Expanded "${node.title}" - found ${linksAdded} linked articles`, - }, - ], - structuredContent: { - success: true, - node: { url: node.url, title: node.title }, - linksAdded, - }, - }; - } catch (e) { - setNodeState(node.url, "error", "Request failed"); - updateGraph(); - return { - content: [ - { - type: "text" as const, - text: `Failed to expand "${node.title}": ${e instanceof Error ? e.message : String(e)}`, - }, - ], - structuredContent: { - success: false, - error: String(e), - }, - }; - } - }, -); - -// Tool: Get list of currently visible nodes in the graph -app.registerTool( - "get-visible-nodes", - { - title: "Get Visible Nodes", - description: "Get a list of all nodes currently visible in the graph", - }, - async () => { - const nodes = graphData.nodes.map((n) => ({ - url: n.url, - title: n.title, - state: n.state, - isExpanded: n.state === "expanded", - hasError: n.state === "error", - })); - - const expandedCount = nodes.filter((n) => n.isExpanded).length; - const errorCount = nodes.filter((n) => n.hasError).length; - - return { - content: [ - { - type: "text" as const, - text: `Graph contains ${nodes.length} nodes:\n${nodes.map((n) => `- ${n.title} (${n.state})`).join("\n")}`, - }, - ], - structuredContent: { - totalNodes: nodes.length, - expandedNodes: expandedCount, - errorNodes: errorCount, - nodes, - }, - }; - }, -); - // Connect to host app.connect().then(() => { const ctx = app.getHostContext(); diff --git a/examples/wiki-explorer-server/tsconfig.json b/examples/wiki-explorer-server/tsconfig.json index b028ff5..535267b 100644 --- a/examples/wiki-explorer-server/tsconfig.json +++ b/examples/wiki-explorer-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/wiki-explorer-server/tsconfig.server.json b/examples/wiki-explorer-server/tsconfig.server.json index 27eb28f..05ddd8e 100644 --- a/examples/wiki-explorer-server/tsconfig.server.json +++ b/examples/wiki-explorer-server/tsconfig.server.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/media/claude-colorpicker-apps.gif b/media/claude-colorpicker-apps.gif deleted file mode 100644 index 1b64ee9..0000000 Binary files a/media/claude-colorpicker-apps.gif and /dev/null differ diff --git a/media/excalidraw.gif b/media/excalidraw.gif deleted file mode 100644 index 848e1c2..0000000 Binary files a/media/excalidraw.gif and /dev/null differ diff --git a/media/mcp-white.svg b/media/mcp-white.svg deleted file mode 100644 index cacaef5..0000000 --- a/media/mcp-white.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/media/mcp.svg b/media/mcp.svg deleted file mode 100644 index 03d9f85..0000000 --- a/media/mcp.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/package-lock.json b/package-lock.json index 0189ca2..405f314 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,70 +1,66 @@ { "name": "@modelcontextprotocol/ext-apps", - "version": "2.0.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/ext-apps", - "version": "2.0.0", + "version": "0.4.1", + "hasInstallScript": true, "license": "MIT", "workspaces": [ "examples/*" ], - "dependencies": { - "@standard-schema/spec": "^1.1.0" - }, "devDependencies": { - "@boneskull/typedoc-plugin-mermaid": "^0.2.0", - "@modelcontextprotocol/client": "2.0.0", - "@modelcontextprotocol/core": "2.0.0", - "@modelcontextprotocol/sdk": "1.30.0", - "@modelcontextprotocol/server": "2.0.0", - "@playwright/test": "1.57.0", + "@modelcontextprotocol/sdk": "^1.24.0", + "@playwright/test": "^1.52.0", "@types/bun": "^1.3.2", - "@types/node": "20.19.27", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", - "bun": "^1.2.21", - "caniuse-lite": "1.0.30001763", - "cheerio": "1.1.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "cross-env": "^10.1.0", - "electron-to-chromium": "1.5.267", "esbuild": "^0.25.12", "express": "^5.1.0", - "ext-apps-v1": "npm:@modelcontextprotocol/ext-apps@1.7.5", "husky": "^9.1.7", "nodemon": "^3.1.0", - "playwright": "1.57.0", - "playwright-core": "1.57.0", "prettier": "^3.6.2", "react": "^19.2.0", "react-dom": "^19.2.0", - "sharp": "^0.35.3", + "sharp": "^0.34.5", "ts-to-zod": "^5.1.0", "tsx": "^4.21.0", "typedoc": "^0.28.14", - "typedoc-github-theme": "^0.4.0", "typescript": "^5.9.3", - "zod": "^4.2.0" + "zod": "^4.1.13" }, - "engines": { - "node": ">=20" + "optionalDependencies": { + "@oven/bun-darwin-aarch64": "^1.2.21", + "@oven/bun-darwin-x64": "^1.2.21", + "@oven/bun-darwin-x64-baseline": "^1.2.21", + "@oven/bun-linux-aarch64": "^1.2.21", + "@oven/bun-linux-aarch64-musl": "^1.2.21", + "@oven/bun-linux-x64": "^1.2.21", + "@oven/bun-linux-x64-baseline": "^1.2.21", + "@oven/bun-linux-x64-musl": "^1.2.21", + "@oven/bun-linux-x64-musl-baseline": "^1.2.21", + "@oven/bun-windows-x64": "^1.2.21", + "@oven/bun-windows-x64-baseline": "^1.2.21", + "@rollup/rollup-darwin-arm64": "^4.53.3", + "@rollup/rollup-darwin-x64": "^4.53.3", + "@rollup/rollup-linux-arm64-gnu": "^4.53.3", + "@rollup/rollup-linux-x64-gnu": "^4.53.3", + "@rollup/rollup-win32-arm64-msvc": "^4.53.3", + "@rollup/rollup-win32-x64-msvc": "^4.53.3" }, "peerDependencies": { - "@modelcontextprotocol/client": "^2.0.0", - "@modelcontextprotocol/core": "^2.0.0", - "@modelcontextprotocol/server": "^2.0.0", + "@modelcontextprotocol/sdk": "^1.24.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", - "zod": "^4.2.0" + "zod": "^3.25.0 || ^4.0.0" }, "peerDependenciesMeta": { - "@modelcontextprotocol/server": { - "optional": true - }, "react": { "optional": true }, @@ -75,17 +71,17 @@ }, "examples/basic-host": { "name": "@modelcontextprotocol/ext-apps-basic-host", - "version": "2.0.0", + "version": "0.4.1", "dependencies": { - "@modelcontextprotocol/client": "2.0.0", - "@modelcontextprotocol/ext-apps": "^2.0.0", + "@modelcontextprotocol/ext-apps": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "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", @@ -95,40 +91,36 @@ "prettier": "^3.6.2", "typescript": "^5.9.3", "vite": "^6.0.0", - "vite-plugin-singlefile": "^2.3.0" + "vite-plugin-singlefile": "^2.3.0", + "vitest": "^3.2.4" } }, "examples/basic-host/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "version": "22.19.5", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "examples/basic-host/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, "examples/basic-server-preact": { "name": "@modelcontextprotocol/server-basic-preact", - "version": "2.0.0", + "version": "0.4.1", "license": "MIT", "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", + "@modelcontextprotocol/ext-apps": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", "preact": "^10.0.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "bin": { "mcp-server-basic-preact": "dist/index.js" @@ -137,7 +129,7 @@ "@preact/preset-vite": "^2.0.0", "@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", "typescript": "^5.9.3", @@ -146,37 +138,32 @@ } }, "examples/basic-server-preact/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "version": "22.19.5", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "examples/basic-server-preact/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, "examples/basic-server-react": { "name": "@modelcontextprotocol/server-basic-react", - "version": "2.0.0", + "version": "0.4.1", "license": "MIT", "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", + "@modelcontextprotocol/ext-apps": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "bin": { "mcp-server-basic-react": "dist/index.js" @@ -184,7 +171,7 @@ "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", @@ -196,36 +183,31 @@ } }, "examples/basic-server-react/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "version": "22.19.5", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "examples/basic-server-react/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, "examples/basic-server-solid": { "name": "@modelcontextprotocol/server-basic-solid", - "version": "2.0.0", + "version": "0.4.1", "license": "MIT", "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", + "@modelcontextprotocol/ext-apps": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", - "solid-js": "1.9.10", - "zod": "^4.2.0" + "solid-js": "^1.9.0", + "zod": "^4.1.13" }, "bin": { "mcp-server-basic-solid": "dist/index.js" @@ -233,46 +215,41 @@ "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", "typescript": "^5.9.3", "vite": "^6.0.0", "vite-plugin-singlefile": "^2.3.0", - "vite-plugin-solid": "^2.11.12" + "vite-plugin-solid": "^2.0.0" } }, "examples/basic-server-solid/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "version": "22.19.5", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "examples/basic-server-solid/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, "examples/basic-server-svelte": { "name": "@modelcontextprotocol/server-basic-svelte", - "version": "2.0.0", + "version": "0.4.1", "license": "MIT", "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", + "@modelcontextprotocol/ext-apps": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", "svelte": "^5.0.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "bin": { "mcp-server-basic-svelte": "dist/index.js" @@ -281,7 +258,7 @@ "@sveltejs/vite-plugin-svelte": "^5.0.0", "@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", "typescript": "^5.9.3", @@ -290,35 +267,30 @@ } }, "examples/basic-server-svelte/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "version": "22.19.5", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "examples/basic-server-svelte/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, "examples/basic-server-vanillajs": { "name": "@modelcontextprotocol/server-basic-vanillajs", - "version": "2.0.0", + "version": "0.4.1", "license": "MIT", "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", + "@modelcontextprotocol/ext-apps": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "bin": { "mcp-server-basic-vanillajs": "dist/index.js" @@ -326,7 +298,7 @@ "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", "typescript": "^5.9.3", @@ -335,36 +307,31 @@ } }, "examples/basic-server-vanillajs/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "version": "22.19.5", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "examples/basic-server-vanillajs/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, "examples/basic-server-vue": { "name": "@modelcontextprotocol/server-basic-vue", - "version": "2.0.0", + "version": "0.4.1", "license": "MIT", "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", + "@modelcontextprotocol/ext-apps": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", "vue": "^3.5.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "bin": { "mcp-server-basic-vue": "dist/index.js" @@ -372,7 +339,7 @@ "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.0", - "@types/node": "22.10.0", + "@types/node": "^22.0.0", "@vitejs/plugin-vue": "^5.0.0", "concurrently": "^9.2.1", "cross-env": "^10.1.0", @@ -382,35 +349,31 @@ } }, "examples/basic-server-vue/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "version": "22.19.5", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "examples/basic-server-vue/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, "examples/budget-allocator-server": { "name": "@modelcontextprotocol/server-budget-allocator", - "version": "2.0.0", + "version": "0.4.1", "license": "MIT", "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": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "chart.js": "^4.4.0", "cors": "^2.8.5", "express": "^5.1.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "bin": { "mcp-budget-allocator-server": "dist/index.js" @@ -418,7 +381,7 @@ "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", "typescript": "^5.9.3", @@ -427,36 +390,32 @@ } }, "examples/budget-allocator-server/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "version": "22.19.5", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "examples/budget-allocator-server/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, "examples/cohort-heatmap-server": { "name": "@modelcontextprotocol/server-cohort-heatmap", - "version": "2.0.0", + "version": "0.4.1", "license": "MIT", "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": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "bin": { "mcp-cohort-heatmap-server": "dist/index.js" @@ -464,7 +423,7 @@ "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", @@ -476,35 +435,31 @@ } }, "examples/cohort-heatmap-server/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "version": "22.19.5", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "examples/cohort-heatmap-server/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, "examples/customer-segmentation-server": { "name": "@modelcontextprotocol/server-customer-segmentation", - "version": "2.0.0", + "version": "0.4.1", "license": "MIT", "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": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "chart.js": "^4.4.0", "cors": "^2.8.5", "express": "^5.1.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "bin": { "mcp-customer-segmentation-server": "dist/index.js" @@ -512,7 +467,7 @@ "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", "typescript": "^5.9.3", @@ -521,79 +476,30 @@ } }, "examples/customer-segmentation-server/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "version": "22.19.5", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "examples/customer-segmentation-server/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "dev": true, - "license": "MIT" - }, - "examples/debug-server": { - "name": "@modelcontextprotocol/server-debug", - "version": "2.0.0", - "license": "MIT", - "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" - }, - "bin": { - "mcp-server-debug": "dist/index.js" - }, - "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" - } - }, - "examples/debug-server/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.20.0" - } - }, - "examples/debug-server/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, "examples/integration-server": { - "version": "2.0.0", + "version": "1.0.0", "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", + "@modelcontextprotocol/ext-apps": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "bin": { "mcp-integration-server": "dist/index.js" @@ -601,7 +507,7 @@ "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", @@ -612,79 +518,30 @@ } }, "examples/integration-server/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "version": "22.19.5", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "examples/integration-server/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "dev": true, - "license": "MIT" - }, - "examples/lazy-auth-server": { - "name": "@modelcontextprotocol/server-lazy-auth", - "version": "2.0.0", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/ext-apps": "^2.0.0", - "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", - "cors": "^2.8.5", - "express": "^5.1.0", - "jose": "^6.0.0", - "zod": "^4.2.0" - }, - "bin": { - "mcp-server-lazy-auth": "dist/index.js" - }, - "devDependencies": { - "@types/cors": "^2.8.19", - "@types/express": "^5.0.0", - "@types/node": "22.10.0", - "concurrently": "^9.2.1", - "cross-env": "^10.1.0", - "typescript": "^5.9.3", - "vite": "^6.0.0", - "vite-plugin-singlefile": "^2.3.0" - } - }, - "examples/lazy-auth-server/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.20.0" - } - }, - "examples/lazy-auth-server/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, "examples/map-server": { "name": "@modelcontextprotocol/server-map", - "version": "2.0.0", + "version": "0.4.1", "license": "MIT", "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", + "@modelcontextprotocol/ext-apps": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "bin": { "mcp-map-server": "dist/index.js" @@ -692,7 +549,7 @@ "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", "typescript": "^5.9.3", @@ -701,37 +558,31 @@ } }, "examples/map-server/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "version": "22.19.5", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "examples/map-server/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, "examples/pdf-server": { "name": "@modelcontextprotocol/server-pdf", - "version": "2.0.0", + "version": "0.4.1", "license": "MIT", "dependencies": { - "@cantoo/pdf-lib": "^2.6.5", - "@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", + "@modelcontextprotocol/ext-apps": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", "pdfjs-dist": "^5.0.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "bin": { "mcp-pdf-server": "dist/index.js" @@ -739,7 +590,7 @@ "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", "typescript": "^5.9.3", @@ -748,87 +599,37 @@ } }, "examples/pdf-server/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.20.0" - } - }, - "examples/pdf-server/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "dev": true, - "license": "MIT" - }, - "examples/qr-server": { - "name": "@modelcontextprotocol/server-qr", - "version": "2.0.0", - "dependencies": { - "@modelcontextprotocol/ext-apps": "^2.0.0" - } - }, - "examples/quickstart": { - "name": "@modelcontextprotocol/quickstart", - "version": "2.0.0", - "license": "MIT", - "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", - "zod": "^4.2.0" - }, - "devDependencies": { - "@types/cors": "^2.8.19", - "@types/express": "^5.0.0", - "@types/node": "22.19.5", - "concurrently": "^9.2.1", - "cross-env": "^10.1.0", - "tsx": "^4.21.0", - "typescript": "^5.9.3", - "vite": "^6.0.0", - "vite-plugin-singlefile": "^2.3.0" - } - }, - "examples/quickstart/node_modules/@types/node": { - "version": "22.19.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.5.tgz", - "integrity": "sha512-HfF8+mYcHPcPypui3w3mvzuIErlNOh2OAG+BCeBZCEwyiD5ls2SiCwEyT47OELtf7M3nHxBdu0FsmzdKxkN52Q==", + "version": "22.19.6", "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, - "examples/say-server": { - "name": "@modelcontextprotocol/server-say", - "version": "2.0.0", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/ext-apps": "^2.0.0" - } + "examples/pdf-server/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "examples/qr-server": { + "name": "@modelcontextprotocol/server-qr", + "version": "1.0.0" }, "examples/scenario-modeler-server": { "name": "@modelcontextprotocol/server-scenario-modeler", - "version": "2.0.0", + "version": "0.4.1", "license": "MIT", "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": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "chart.js": "^4.4.0", "cors": "^2.8.5", "express": "^5.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "bin": { "mcp-scenario-modeler-server": "dist/index.js" @@ -836,7 +637,7 @@ "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", @@ -848,34 +649,30 @@ } }, "examples/scenario-modeler-server/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "version": "22.19.5", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "examples/scenario-modeler-server/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, "examples/shadertoy-server": { "name": "@modelcontextprotocol/server-shadertoy", - "version": "2.0.0", + "version": "0.4.1", "license": "MIT", "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": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "bin": { "mcp-shadertoy-server": "dist/index.js" @@ -883,7 +680,7 @@ "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", "typescript": "^5.9.3", @@ -892,35 +689,31 @@ } }, "examples/shadertoy-server/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "version": "22.19.5", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "examples/shadertoy-server/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, "examples/sheet-music-server": { "name": "@modelcontextprotocol/server-sheet-music", - "version": "2.0.0", + "version": "0.4.1", "license": "MIT", "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": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "abcjs": "^6.4.4", "cors": "^2.8.5", "express": "^5.1.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "bin": { "mcp-sheet-music-server": "dist/index.js" @@ -928,7 +721,7 @@ "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", "typescript": "^5.9.3", @@ -937,36 +730,32 @@ } }, "examples/sheet-music-server/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "version": "22.19.5", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "examples/sheet-music-server/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, "examples/system-monitor-server": { "name": "@modelcontextprotocol/server-system-monitor", - "version": "2.0.0", + "version": "0.4.1", "license": "MIT", "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": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "chart.js": "^4.4.0", "cors": "^2.8.5", "express": "^5.1.0", - "systeminformation": "^5.31.6", - "zod": "^4.2.0" + "systeminformation": "^5.27.11", + "zod": "^4.1.13" }, "bin": { "mcp-system-monitor-server": "dist/index.js" @@ -974,7 +763,7 @@ "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", "typescript": "^5.9.3", @@ -983,38 +772,33 @@ } }, "examples/system-monitor-server/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "version": "22.19.5", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "examples/system-monitor-server/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, "examples/threejs-server": { "name": "@modelcontextprotocol/server-threejs", - "version": "2.0.0", + "version": "0.4.1", "license": "MIT", "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", + "@modelcontextprotocol/ext-apps": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", "three": "^0.181.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "bin": { "mcp-threejs-server": "dist/index.js" @@ -1022,7 +806,7 @@ "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", "@types/three": "^0.181.0", @@ -1035,34 +819,30 @@ } }, "examples/threejs-server/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "version": "22.19.5", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "examples/threejs-server/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, "examples/transcript-server": { "name": "@modelcontextprotocol/server-transcript", - "version": "2.0.0", + "version": "0.4.1", "license": "MIT", "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": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", - "zod": "^4.2.0" + "zod": "^3.23.0" }, "bin": { "mcp-transcript-server": "dist/index.js" @@ -1071,7 +851,7 @@ "@types/cors": "^2.8.19", "@types/dom-speech-recognition": "^0.0.7", "@types/express": "^5.0.0", - "@types/node": "22.10.0", + "@types/node": "^22.0.0", "concurrently": "^9.2.1", "cross-env": "^10.1.0", "typescript": "^5.9.3", @@ -1080,35 +860,39 @@ } }, "examples/transcript-server/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "version": "22.19.5", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "examples/transcript-server/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, + "examples/transcript-server/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "examples/video-resource-server": { "name": "@modelcontextprotocol/server-video-resource", - "version": "2.0.0", + "version": "0.4.1", "license": "MIT", "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", + "@modelcontextprotocol/ext-apps": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "bin": { "mcp-video-resource-server": "dist/index.js" @@ -1116,7 +900,7 @@ "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", "typescript": "^5.9.3", @@ -1125,36 +909,31 @@ } }, "examples/video-resource-server/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "version": "22.19.5", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "examples/video-resource-server/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, "examples/wiki-explorer-server": { "name": "@modelcontextprotocol/server-wiki-explorer", - "version": "2.0.0", + "version": "0.4.1", "license": "MIT", "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", + "@modelcontextprotocol/ext-apps": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.24.0", "cheerio": "^1.0.0", "cors": "^2.8.5", "express": "^5.1.0", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "bin": { "mcp-wiki-explorer-server": "dist/index.js" @@ -1162,7 +941,7 @@ "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", "force-graph": "^1.49.0", @@ -1172,30 +951,28 @@ } }, "examples/wiki-explorer-server/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "version": "22.19.5", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "examples/wiki-explorer-server/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -1204,9 +981,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", "dev": true, "license": "MIT", "engines": { @@ -1214,21 +991,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -1255,14 +1032,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -1285,14 +1062,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -1312,9 +1089,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", "dev": true, "license": "MIT", "engines": { @@ -1322,29 +1099,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1364,27 +1141,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", "engines": { @@ -1392,26 +1169,26 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.28.6" }, "bin": { "parser": "bin/babel-parser.js" @@ -1505,33 +1282,33 @@ } }, "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", "debug": "^4.3.1" }, "engines": { @@ -1539,52 +1316,18 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@boneskull/typedoc-plugin-mermaid": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@boneskull/typedoc-plugin-mermaid/-/typedoc-plugin-mermaid-0.2.0.tgz", - "integrity": "sha512-ggbGNwn6of9ddmOL5/C6+8/REAoqiQ1uyt34AkJxYEev6rZSLBo938rJjHgjodmU3uE7DVptp5fUxAnW1qfVUw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=22.0.0" - }, - "peerDependencies": { - "mermaid": ">=11.0.0", - "typedoc": ">=0.27.0 || ^0.28.0" - }, - "peerDependenciesMeta": { - "mermaid": { - "optional": true - } - } - }, - "node_modules/@cantoo/pdf-lib": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@cantoo/pdf-lib/-/pdf-lib-2.6.5.tgz", - "integrity": "sha512-3eMHEaqKHt/G/q+6QjT06A3lz0S/a8x3+myiSN7FNeL3uWcedO0lpfs6TWofa4C03Z1wz3tWeHoa4CsI7DrTSA==", - "license": "MIT", - "dependencies": { - "@pdf-lib/standard-fonts": "^1.0.0", - "@pdf-lib/upng": "^1.0.1", - "color": "^4.2.3", - "crypto-js": "^4.2.0", - "node-html-better-parser": ">=1.4.0", - "pako": "^1.0.11", - "tslib": ">=2" - } - }, "node_modules/@clack/core": { "version": "1.0.0-alpha.4", "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.0.0-alpha.4.tgz", @@ -1616,9 +1359,9 @@ "license": "Apache-2.0" }, "node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "dev": true, "license": "MIT", "optional": true, @@ -2090,9 +1833,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -2102,9 +1845,9 @@ } }, "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", "dev": true, "license": "MIT", "engines": { @@ -2112,9 +1855,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" ], @@ -2125,19 +1868,19 @@ "darwin" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" ], @@ -2148,39 +1891,19 @@ "darwin" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" - } - }, - "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "dependencies": { - "@img/sharp-wasm32": "0.35.3" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], @@ -2195,9 +1918,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ "x64" ], @@ -2212,9 +1935,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ "arm" ], @@ -2229,9 +1952,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ "arm64" ], @@ -2246,9 +1969,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", "cpu": [ "ppc64" ], @@ -2263,9 +1986,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", "cpu": [ "riscv64" ], @@ -2280,9 +2003,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "cpu": [ "s390x" ], @@ -2297,9 +2020,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], @@ -2314,9 +2037,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], @@ -2331,9 +2054,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], @@ -2348,9 +2071,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ "arm" ], @@ -2361,19 +2084,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ "arm64" ], @@ -2384,19 +2107,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" + "@img/sharp-libvips-linux-arm64": "1.2.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", "cpu": [ "ppc64" ], @@ -2407,19 +2130,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" + "@img/sharp-libvips-linux-ppc64": "1.2.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", "cpu": [ "riscv64" ], @@ -2430,19 +2153,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" + "@img/sharp-libvips-linux-riscv64": "1.2.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "cpu": [ "s390x" ], @@ -2453,19 +2176,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" + "@img/sharp-libvips-linux-s390x": "1.2.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ "x64" ], @@ -2476,19 +2199,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" + "@img/sharp-libvips-linux-x64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], @@ -2499,19 +2222,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], @@ -2522,56 +2245,39 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.11.1" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "cpu": [ "wasm32" ], "dev": true, - "license": "Apache-2.0", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@emnapi/runtime": "^1.7.0" }, "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", "cpu": [ "arm64" ], @@ -2582,16 +2288,16 @@ "win32" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ "ia32" ], @@ -2602,16 +2308,16 @@ "win32" ], "engines": { - "node": "^20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], @@ -2622,7 +2328,7 @@ "win32" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -2679,52 +2385,6 @@ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "license": "MIT" }, - "node_modules/@modelcontextprotocol/client": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", - "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "jose": "^6.1.3", - "pkce-challenge": "^5.0.0", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@modelcontextprotocol/core": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", - "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", - "license": "MIT", - "dependencies": { - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@modelcontextprotocol/express": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/express/-/express-2.0.0.tgz", - "integrity": "sha512-Snlr8j9FR9LcVvEJPF7qJ7d5zTL4Bes2dk7RcacN9eSZ7OLohwxqhEvWu1+UxELyScgLbLLOdeVEGdwKI1iwVQ==", - "license": "MIT", - "dependencies": { - "cors": "^2.8.5" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@modelcontextprotocol/server": "^2.0.0", - "express": "^4.18.0 || ^5.0.0" - } - }, "node_modules/@modelcontextprotocol/ext-apps": { "resolved": "", "link": true @@ -2733,39 +2393,13 @@ "resolved": "examples/basic-host", "link": true }, - "node_modules/@modelcontextprotocol/node": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/node/-/node-2.0.0.tgz", - "integrity": "sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg==", - "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.19.9" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@modelcontextprotocol/server": "^2.0.0", - "hono": "^4.11.4" - }, - "peerDependenciesMeta": { - "hono": { - "optional": true - } - } - }, - "node_modules/@modelcontextprotocol/quickstart": { - "resolved": "examples/quickstart", - "link": true - }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", - "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", - "dev": true, + "version": "1.25.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", + "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9 || ^2.0.5", + "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -2773,15 +2407,14 @@ "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" + "zod-to-json-schema": "^3.25.0" }, "engines": { "node": ">=18" @@ -2799,19 +2432,6 @@ } } }, - "node_modules/@modelcontextprotocol/server": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", - "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/@modelcontextprotocol/server-basic-preact": { "resolved": "examples/basic-server-preact", "link": true @@ -2848,14 +2468,6 @@ "resolved": "examples/customer-segmentation-server", "link": true }, - "node_modules/@modelcontextprotocol/server-debug": { - "resolved": "examples/debug-server", - "link": true - }, - "node_modules/@modelcontextprotocol/server-lazy-auth": { - "resolved": "examples/lazy-auth-server", - "link": true - }, "node_modules/@modelcontextprotocol/server-map": { "resolved": "examples/map-server", "link": true @@ -2868,10 +2480,6 @@ "resolved": "examples/qr-server", "link": true }, - "node_modules/@modelcontextprotocol/server-say": { - "resolved": "examples/say-server", - "link": true - }, "node_modules/@modelcontextprotocol/server-scenario-modeler": { "resolved": "examples/scenario-modeler-server", "link": true @@ -3185,9 +2793,9 @@ } }, "node_modules/@oclif/core/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3195,13 +2803,13 @@ } }, "node_modules/@oclif/core/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^2.0.1" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -3211,13 +2819,12 @@ } }, "node_modules/@oven/bun-darwin-aarch64": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.10.tgz", - "integrity": "sha512-PXgg5gqcS/rHwa1hF0JdM1y5TiyejVrMHoBmWY/DjtfYZoFTXie1RCFOkoG0b5diOOmUcuYarMpH7CSNTqwj+w==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.6.tgz", + "integrity": "sha512-27rypIapNkYboOSylkf1tD9UW9Ado2I+P1NBL46Qz29KmOjTL6WuJ7mHDC5O66CYxlOkF5r93NPDAC3lFHYBXw==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3225,13 +2832,12 @@ ] }, "node_modules/@oven/bun-darwin-x64": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.10.tgz", - "integrity": "sha512-Nhssuh7GBpP5PiDSOl3+qnoIG7PJo+ec2oomDevnl9pRY6x6aD2gRt0JE+uf+A8Om2D6gjeHCxjEdrw5ZHE8mA==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.6.tgz", + "integrity": "sha512-I82xGzPkBxzBKgbl8DsA0RfMQCWTWjNmLjIEkW1ECiv3qK02kHGQ5FGUr/29L/SuvnGsULW4tBTRNZiMzL37nA==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3239,13 +2845,12 @@ ] }, "node_modules/@oven/bun-darwin-x64-baseline": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.10.tgz", - "integrity": "sha512-w1gaTlqU0IJCmJ1X+PGHkdNU1n8Gemx5YKkjhkJIguvFINXEBB5U1KG82QsT65Tk4KyNMfbLTlmy4giAvUoKfA==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.6.tgz", + "integrity": "sha512-nqtr+pTsHqusYpG2OZc6s+AmpWDB/FmBvstrK0y5zkti4OqnCuu7Ev2xNjS7uyb47NrAFF40pWqkpaio5XEd7w==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3253,13 +2858,12 @@ ] }, "node_modules/@oven/bun-linux-aarch64": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.10.tgz", - "integrity": "sha512-OUgPHfL6+PM2Q+tFZjcaycN3D7gdQdYlWnwMI31DXZKY1r4HINWk9aEz9t/rNaHg65edwNrt7dsv9TF7xK8xIA==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.6.tgz", + "integrity": "sha512-YaQEAYjBanoOOtpqk/c5GGcfZIyxIIkQ2m1TbHjedRmJNwxzWBhGinSARFkrRIc3F8pRIGAopXKvJ/2rjN1LzQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3267,13 +2871,12 @@ ] }, "node_modules/@oven/bun-linux-aarch64-musl": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.10.tgz", - "integrity": "sha512-Ui5pAgM7JE9MzHokF0VglRMkbak3lTisY4Mf1AZutPACXWgKJC5aGrgnHBfkl7QS6fEeYb0juy1q4eRznRHOsw==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.6.tgz", + "integrity": "sha512-FR+iJt17rfFgYgpxL3M67AUwujOgjw52ZJzB9vElI5jQXNjTyOKf8eH4meSk4vjlYF3h/AjKYd6pmN0OIUlVKQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3281,13 +2884,12 @@ ] }, "node_modules/@oven/bun-linux-x64": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.10.tgz", - "integrity": "sha512-bzUgYj/PIZziB/ZesIP9HUyfvh6Vlf3od+TrbTTyVEuCSMKzDPQVW/yEbRp0tcHO3alwiEXwJDrWrHAguXlgiQ==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.6.tgz", + "integrity": "sha512-egfngj0dfJ868cf30E7B+ye9KUWSebYxOG4l9YP5eWeMXCtenpenx0zdKtAn9qxJgEJym5AN6trtlk+J6x8Lig==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3295,13 +2897,12 @@ ] }, "node_modules/@oven/bun-linux-x64-baseline": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.10.tgz", - "integrity": "sha512-oqvMDYpX6dGJO03HgO5bXuccEsH3qbdO3MaAiAlO4CfkBPLUXz3N0DDElg5hz0L6ktdDVKbQVE5lfe+LAUISQg==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.6.tgz", + "integrity": "sha512-jRmnX18ak8WzqLrex3siw0PoVKyIeI5AiCv4wJLgSs7VKfOqrPycfHIWfIX2jdn7ngqbHFPzI09VBKANZ4Pckg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3309,13 +2910,12 @@ ] }, "node_modules/@oven/bun-linux-x64-musl": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.10.tgz", - "integrity": "sha512-poVXvOShekbexHq45b4MH/mRjQKwACAC8lHp3Tz/hEDuz0/20oncqScnmKwzhBPEpqJvydXficXfBYuSim8opw==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.6.tgz", + "integrity": "sha512-YeXcJ9K6vJAt1zSkeA21J6pTe7PgDMLTHKGI3nQBiMYnYf7Ob3K+b/ChSCznrJG7No5PCPiQPg4zTgA+BOTmSA==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3323,41 +2923,25 @@ ] }, "node_modules/@oven/bun-linux-x64-musl-baseline": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.10.tgz", - "integrity": "sha512-/hOZ6S1VsTX6vtbhWVL9aAnOrdpuO54mAGUWpTdMz7dFG5UBZ/VUEiK0pBkq9A1rlBk0GeD/6Y4NBFl8Ha7cRA==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.6.tgz", + "integrity": "sha512-7FjVnxnRTp/AgWqSQRT/Vt9TYmvnZ+4M+d9QOKh/Lf++wIFXFGSeAgD6bV1X/yr2UPVmZDk+xdhr2XkU7l2v3w==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@oven/bun-windows-aarch64": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.3.10.tgz", - "integrity": "sha512-GXbz2swvN2DLw2dXZFeedMxSJtI64xQ9xp9Eg7Hjejg6mS2E4dP1xoQ2yAo2aZPi/2OBPAVaGzppI2q20XumHA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@oven/bun-windows-x64": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.10.tgz", - "integrity": "sha512-qaS1In3yfC/Z/IGQriVmF8GWwKuNqiw7feTSJWaQhH5IbL6ENR+4wGNPniZSJFaM/SKUO0e/YCRdoVBvgU4C1g==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.6.tgz", + "integrity": "sha512-Sr1KwUcbB0SEpnSPO22tNJppku2khjFluEst+mTGhxHzAGQTQncNeJxDnt3F15n+p9Q+mlcorxehd68n1siikQ==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3365,37 +2949,18 @@ ] }, "node_modules/@oven/bun-windows-x64-baseline": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.10.tgz", - "integrity": "sha512-gh3UAHbUdDUG6fhLc1Csa4IGdtghue6U8oAIXWnUqawp6lwb3gOCRvp25IUnLF5vUHtgfMxuEUYV7YA2WxVutw==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.6.tgz", + "integrity": "sha512-PFUa7JL4lGoyyppeS4zqfuoXXih+gSE0XxhDMrCPVEUev0yhGNd/tbWBvcdpYnUth80owENoGjc8s5Knopv9wA==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ] }, - "node_modules/@pdf-lib/standard-fonts": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", - "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", - "license": "MIT", - "dependencies": { - "pako": "^1.0.6" - } - }, - "node_modules/@pdf-lib/upng": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", - "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", - "license": "MIT", - "dependencies": { - "pako": "^1.0.10" - } - }, "node_modules/@playwright/test": { "version": "1.57.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", @@ -3497,9 +3062,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", "cpu": [ "arm" ], @@ -3511,9 +3076,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", "cpu": [ "arm64" ], @@ -3525,13 +3090,12 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3539,13 +3103,12 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3553,9 +3116,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", "cpu": [ "arm64" ], @@ -3567,9 +3130,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", "cpu": [ "x64" ], @@ -3581,9 +3144,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", "cpu": [ "arm" ], @@ -3595,9 +3158,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", "cpu": [ "arm" ], @@ -3609,13 +3172,12 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3623,9 +3185,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", "cpu": [ "arm64" ], @@ -3637,9 +3199,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", "cpu": [ "loong64" ], @@ -3651,9 +3213,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", "cpu": [ "loong64" ], @@ -3665,9 +3227,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", "cpu": [ "ppc64" ], @@ -3679,9 +3241,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", "cpu": [ "ppc64" ], @@ -3693,9 +3255,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", "cpu": [ "riscv64" ], @@ -3707,9 +3269,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", "cpu": [ "riscv64" ], @@ -3721,9 +3283,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", "cpu": [ "s390x" ], @@ -3735,13 +3297,12 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3749,9 +3310,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", - "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", "cpu": [ "x64" ], @@ -3763,9 +3324,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", "cpu": [ "x64" ], @@ -3777,9 +3338,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", "cpu": [ "arm64" ], @@ -3791,13 +3352,12 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3805,9 +3365,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", "cpu": [ "ia32" ], @@ -3819,9 +3379,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", "cpu": [ "x64" ], @@ -3833,13 +3393,12 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3895,16 +3454,10 @@ "dev": true, "license": "MIT" }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", - "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", + "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -4023,6 +3576,17 @@ "bun-types": "1.3.6" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -4043,6 +3607,13 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/dom-speech-recognition": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/@types/dom-speech-recognition/-/dom-speech-recognition-0.0.7.tgz", @@ -4099,13 +3670,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.27", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", - "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", + "version": "25.0.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.8.tgz", + "integrity": "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.16.0" } }, "node_modules/@types/qs": { @@ -4186,12 +3757,6 @@ "meshoptimizer": "~0.22.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT" - }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -4254,23 +3819,148 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vue/compiler-core": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.27.tgz", - "integrity": "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz", + "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", "license": "MIT", "dependencies": { "@babel/parser": "^7.28.5", - "@vue/shared": "3.5.27", + "@vue/shared": "3.5.26", "entities": "^7.0.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-core/node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", + "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -4280,26 +3970,26 @@ } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz", - "integrity": "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz", + "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.27", - "@vue/shared": "3.5.27" + "@vue/compiler-core": "3.5.26", + "@vue/shared": "3.5.26" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz", - "integrity": "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz", + "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==", "license": "MIT", "dependencies": { "@babel/parser": "^7.28.5", - "@vue/compiler-core": "3.5.27", - "@vue/compiler-dom": "3.5.27", - "@vue/compiler-ssr": "3.5.27", - "@vue/shared": "3.5.27", + "@vue/compiler-core": "3.5.26", + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", @@ -4307,63 +3997,63 @@ } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz", - "integrity": "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz", + "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.27", - "@vue/shared": "3.5.27" + "@vue/compiler-dom": "3.5.26", + "@vue/shared": "3.5.26" } }, "node_modules/@vue/reactivity": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.27.tgz", - "integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz", + "integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==", "license": "MIT", "dependencies": { - "@vue/shared": "3.5.27" + "@vue/shared": "3.5.26" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.27.tgz", - "integrity": "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz", + "integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.27", - "@vue/shared": "3.5.27" + "@vue/reactivity": "3.5.26", + "@vue/shared": "3.5.26" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz", - "integrity": "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz", + "integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.27", - "@vue/runtime-core": "3.5.27", - "@vue/shared": "3.5.27", + "@vue/reactivity": "3.5.26", + "@vue/runtime-core": "3.5.26", + "@vue/shared": "3.5.26", "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.27.tgz", - "integrity": "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz", + "integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==", "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.27", - "@vue/shared": "3.5.27" + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26" }, "peerDependencies": { - "vue": "3.5.27" + "vue": "3.5.26" } }, "node_modules/@vue/shared": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.27.tgz", - "integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz", + "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", "license": "MIT" }, "node_modules/@webgpu/types": { @@ -4407,9 +4097,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -4419,10 +4109,9 @@ } }, "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -4439,7 +4128,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -4530,14 +4218,24 @@ "license": "Python-2.0" }, "node_modules/aria-query": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", - "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "license": "Apache-2.0", "engines": { "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -4555,9 +4253,9 @@ } }, "node_modules/babel-plugin-jsx-dom-expressions": { - "version": "0.40.6", - "resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.40.6.tgz", - "integrity": "sha512-v3P1MW46Lm7VMpAkq0QfyzLWWkC8fh+0aE5Km4msIgDx5kjenHU0pF2s+4/NH8CQn/kla6+Hvws+2AF7bfV5qQ==", + "version": "0.40.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.40.3.tgz", + "integrity": "sha512-5HOwwt0BYiv/zxl7j8Pf2bGL6rDXfV6nUhLs8ygBX+EFJXzBPHM/euj9j/6deMZ6wa52Wb2PBaAV5U/jKwIY1w==", "dev": true, "license": "MIT", "dependencies": { @@ -4595,17 +4293,17 @@ } }, "node_modules/babel-preset-solid": { - "version": "1.9.12", - "resolved": "https://registry.npmjs.org/babel-preset-solid/-/babel-preset-solid-1.9.12.tgz", - "integrity": "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==", + "version": "1.9.10", + "resolved": "https://registry.npmjs.org/babel-preset-solid/-/babel-preset-solid-1.9.10.tgz", + "integrity": "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jsx-dom-expressions": "^0.40.6" + "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", - "solid-js": "^1.9.12" + "solid-js": "^1.9.10" }, "peerDependenciesMeta": { "solid-js": { @@ -4621,16 +4319,13 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", - "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", + "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" + "baseline-browser-mapping": "dist/cli.js" } }, "node_modules/bezier-js": { @@ -4658,20 +4353,20 @@ } }, "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^2.0.0", + "content-type": "^1.0.5", "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" @@ -4681,35 +4376,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/body-parser/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/body-parser/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -4717,9 +4383,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -4741,9 +4407,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.7", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", - "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -4761,11 +4427,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.44", - "caniuse-lite": "^1.0.30001806", - "electron-to-chromium": "^1.5.393", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -4774,69 +4440,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/browserslist/node_modules/caniuse-lite": { - "version": "1.0.30001806", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", - "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/browserslist/node_modules/electron-to-chromium": { - "version": "1.5.395", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz", - "integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==", - "dev": true, - "license": "ISC" - }, - "node_modules/bun": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/bun/-/bun-1.3.10.tgz", - "integrity": "sha512-S/CXaXXIyA4CMjdMkYQ4T2YMqnAn4s0ysD3mlsY4bUiOCqGlv28zck4Wd4H4kpvbekx15S9mUeLQ7Uxd0tYTLA==", - "cpu": [ - "arm64", - "x64" - ], - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "os": [ - "darwin", - "linux", - "win32" - ], - "bin": { - "bun": "bin/bun.exe", - "bunx": "bin/bunx.exe" - }, - "optionalDependencies": { - "@oven/bun-darwin-aarch64": "1.3.10", - "@oven/bun-darwin-x64": "1.3.10", - "@oven/bun-darwin-x64-baseline": "1.3.10", - "@oven/bun-linux-aarch64": "1.3.10", - "@oven/bun-linux-aarch64-musl": "1.3.10", - "@oven/bun-linux-x64": "1.3.10", - "@oven/bun-linux-x64-baseline": "1.3.10", - "@oven/bun-linux-x64-musl": "1.3.10", - "@oven/bun-linux-x64-musl-baseline": "1.3.10", - "@oven/bun-windows-aarch64": "1.3.10", - "@oven/bun-windows-x64": "1.3.10", - "@oven/bun-windows-x64-baseline": "1.3.10" - } - }, "node_modules/bun-types": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.6.tgz", @@ -4856,6 +4459,16 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -4886,9 +4499,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001763", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz", - "integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==", + "version": "1.0.30001764", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", + "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", "dev": true, "funding": [ { @@ -4919,6 +4532,23 @@ "node": ">=12" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4961,6 +4591,16 @@ "pnpm": ">=8" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/cheerio": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.2.tgz", @@ -5154,23 +4794,11 @@ "node": ">=6" } }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -5183,18 +4811,9 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -5210,15 +4829,15 @@ "license": "MIT" }, "node_modules/concurrently": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", - "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==", + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", "dev": true, "license": "MIT", "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", - "shell-quote": "1.9.0", + "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" @@ -5326,12 +4945,6 @@ "node": ">= 8" } }, - "node_modules/crypto-js": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", - "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", - "license": "MIT" - }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -5448,9 +5061,9 @@ } }, "node_modules/d3-format": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", - "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.1.tgz", + "integrity": "sha512-ryitBnaRbXQtgZ/gU50GSn6jQRwinSCQclpakXymvLd8ytTgE5bmSfgYcUxD7XYL34qHhFDyVk71qqKsfSyvmA==", "dev": true, "license": "ISC", "engines": { @@ -5618,6 +5231,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -5648,9 +5271,9 @@ } }, "node_modules/devalue": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", - "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.1.tgz", + "integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==", "license": "MIT" }, "node_modules/dom-serializer": { @@ -5780,6 +5403,18 @@ "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" } }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -5823,6 +5458,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -5913,20 +5555,12 @@ "license": "MIT" }, "node_modules/esrap": { - "version": "2.2.11", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.11.tgz", - "integrity": "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.1.tgz", + "integrity": "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "peerDependencies": { - "@typescript-eslint/types": "^8.2.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/types": { - "optional": true - } } }, "node_modules/estree-walker": { @@ -5972,6 +5606,16 @@ "node": ">=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -6016,15 +5660,10 @@ } }, "node_modules/express-rate-limit": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", - "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", - "dev": true, + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "ip-address": "^10.2.0" - }, "engines": { "node": ">= 16" }, @@ -6035,49 +5674,16 @@ "express": ">= 4.11" } }, - "node_modules/ext-apps-v1": { - "name": "@modelcontextprotocol/ext-apps", - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.5.tgz", - "integrity": "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg==", - "dev": true, - "license": "MIT", - "workspaces": [ - "examples/*" - ], - "dependencies": { - "@standard-schema/spec": "^1.1.0" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", - "dev": true, + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "funding": [ { "type": "github", @@ -6108,9 +5714,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6118,9 +5724,9 @@ } }, "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "dev": true, "license": "ISC", "dependencies": { @@ -6411,10 +6017,11 @@ } }, "node_modules/hono": { - "version": "4.12.31", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", - "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", + "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -6423,6 +6030,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", + "dev": true, "license": "MIT" }, "node_modules/htmlparser2": { @@ -6493,15 +6101,19 @@ } }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ignore-by-default": { @@ -6551,16 +6163,6 @@ "node": ">=12" } }, - "node_modules/ip-address": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", - "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -6570,12 +6172,6 @@ "node": ">= 0.10" } }, - "node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", - "license": "MIT" - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -6752,14 +6348,12 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, "license": "MIT" }, "node_modules/json-schema-typed": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "dev": true, "license": "BSD-2-Clause" }, "node_modules/json5": { @@ -6819,20 +6413,10 @@ } }, "node_modules/linkify-it": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", - "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/markdown-it" - } - ], "license": "MIT", "dependencies": { "uc.micro": "^2.0.0" @@ -6919,9 +6503,9 @@ "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", - "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "version": "4.17.22", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", + "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", "dev": true, "license": "MIT" }, @@ -7017,6 +6601,13 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7044,25 +6635,15 @@ } }, "node_modules/markdown-it": { - "version": "14.3.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", - "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/markdown-it" - } - ], "license": "MIT", "dependencies": { "argparse": "^2.0.1", - "entities": "^4.5.0", - "linkify-it": "^5.0.2", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" @@ -7184,9 +6765,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", "dependencies": { @@ -7203,9 +6784,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", @@ -7229,15 +6810,6 @@ "node": ">= 0.6" } }, - "node_modules/node-html-better-parser": { - "version": "1.5.8", - "resolved": "https://registry.npmjs.org/node-html-better-parser/-/node-html-better-parser-1.5.8.tgz", - "integrity": "sha512-t/wAKvaTSKco43X+yf9+76RiMt18MtMmzd4wc7rKj+fWav6DV4ajDEKdWlLzSE8USDF5zr/06uGj0Wr/dGAFtw==", - "license": "MIT", - "dependencies": { - "html-entities": "^2.3.2" - } - }, "node_modules/node-html-parser": { "version": "6.1.13", "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", @@ -7250,14 +6822,11 @@ } }, "node_modules/node-releases": { - "version": "2.0.51", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", - "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } + "license": "MIT" }, "node_modules/nodemon": { "version": "3.1.11", @@ -7391,12 +6960,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "license": "(MIT AND Zlib)" - }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -7465,15 +7028,32 @@ } }, "node_modules/path-to-regexp": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.1.tgz", - "integrity": "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pdfjs-dist": { "version": "5.4.530", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.530.tgz", @@ -7493,9 +7073,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", "engines": { @@ -7562,9 +7142,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "funding": [ { "type": "opencollective", @@ -7581,7 +7161,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7646,9 +7226,9 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -7684,22 +7264,6 @@ "node": ">= 0.10" } }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", @@ -7758,7 +7322,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7799,9 +7362,9 @@ "license": "MIT" }, "node_modules/rollup": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", - "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", "dev": true, "license": "MIT", "dependencies": { @@ -7815,31 +7378,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.1", - "@rollup/rollup-android-arm64": "4.60.1", - "@rollup/rollup-darwin-arm64": "4.60.1", - "@rollup/rollup-darwin-x64": "4.60.1", - "@rollup/rollup-freebsd-arm64": "4.60.1", - "@rollup/rollup-freebsd-x64": "4.60.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", - "@rollup/rollup-linux-arm-musleabihf": "4.60.1", - "@rollup/rollup-linux-arm64-gnu": "4.60.1", - "@rollup/rollup-linux-arm64-musl": "4.60.1", - "@rollup/rollup-linux-loong64-gnu": "4.60.1", - "@rollup/rollup-linux-loong64-musl": "4.60.1", - "@rollup/rollup-linux-ppc64-gnu": "4.60.1", - "@rollup/rollup-linux-ppc64-musl": "4.60.1", - "@rollup/rollup-linux-riscv64-gnu": "4.60.1", - "@rollup/rollup-linux-riscv64-musl": "4.60.1", - "@rollup/rollup-linux-s390x-gnu": "4.60.1", - "@rollup/rollup-linux-x64-gnu": "4.60.1", - "@rollup/rollup-linux-x64-musl": "4.60.1", - "@rollup/rollup-openbsd-x64": "4.60.1", - "@rollup/rollup-openharmony-arm64": "4.60.1", - "@rollup/rollup-win32-arm64-msvc": "4.60.1", - "@rollup/rollup-win32-ia32-msvc": "4.60.1", - "@rollup/rollup-win32-x64-gnu": "4.60.1", - "@rollup/rollup-win32-x64-msvc": "4.60.1", + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", "fsevents": "~2.3.2" } }, @@ -7882,9 +7445,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -7921,18 +7484,18 @@ } }, "node_modules/seroval": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.1.tgz", - "integrity": "sha512-9GOc+8T6LN4aByLN75uRvMbrwY5RDBW6lSlknsY4LEa9ZmWcxKcRe1G/Q3HZXjltxMHTrStnvrwAICxZrhldtg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz", + "integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==", "license": "MIT", "engines": { "node": ">=10" } }, "node_modules/seroval-plugins": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.4.2.tgz", - "integrity": "sha512-X7p4MEDTi+60o2sXZ4bnDBhgsUYDSkQEvzYZuJyFqWg9jcoPsHts5nrg5O956py2wyt28lUrBxk0M0/wU8URpA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.3.3.tgz", + "integrity": "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==", "license": "MIT", "engines": { "node": ">=10" @@ -7967,53 +7530,48 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "dev": true, + "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@img/colour": "^1.1.0", + "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", - "semver": "^7.8.5" + "semver": "^7.7.3" }, "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.3", - "@img/sharp-darwin-x64": "0.35.3", - "@img/sharp-freebsd-wasm32": "0.35.3", - "@img/sharp-libvips-darwin-arm64": "1.3.2", - "@img/sharp-libvips-darwin-x64": "1.3.2", - "@img/sharp-libvips-linux-arm": "1.3.2", - "@img/sharp-libvips-linux-arm64": "1.3.2", - "@img/sharp-libvips-linux-ppc64": "1.3.2", - "@img/sharp-libvips-linux-riscv64": "1.3.2", - "@img/sharp-libvips-linux-s390x": "1.3.2", - "@img/sharp-libvips-linux-x64": "1.3.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", - "@img/sharp-libvips-linuxmusl-x64": "1.3.2", - "@img/sharp-linux-arm": "0.35.3", - "@img/sharp-linux-arm64": "0.35.3", - "@img/sharp-linux-ppc64": "0.35.3", - "@img/sharp-linux-riscv64": "0.35.3", - "@img/sharp-linux-s390x": "0.35.3", - "@img/sharp-linux-x64": "0.35.3", - "@img/sharp-linuxmusl-arm64": "0.35.3", - "@img/sharp-linuxmusl-x64": "0.35.3", - "@img/sharp-webcontainers-wasm32": "0.35.3", - "@img/sharp-win32-arm64": "0.35.3", - "@img/sharp-win32-ia32": "0.35.3", - "@img/sharp-win32-x64": "0.35.3" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, "node_modules/shebang-command": { @@ -8038,9 +7596,9 @@ } }, "node_modules/shell-quote": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", - "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "dev": true, "license": "MIT", "engines": { @@ -8122,6 +7680,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -8145,15 +7710,6 @@ "kolorist": "^1.6.0" } }, - "node_modules/simple-swizzle": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", - "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -8272,6 +7828,13 @@ "node": ">=16" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -8281,6 +7844,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -8345,6 +7915,26 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -8362,23 +7952,22 @@ } }, "node_modules/svelte": { - "version": "5.56.1", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.1.tgz", - "integrity": "sha512-eArsJmvl3xZVuTYD852PzIEdg2wgDdIZ1NEsIPbzAukHwi284B18No4nK2rCO9AwsWUDza4Cjvmoa4HaojTl5g==", + "version": "5.46.3", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.3.tgz", + "integrity": "sha512-Y5juST3x+/ySty5tYJCVWa6Corkxpt25bUZQHqOceg9xfMUtDsFx6rCsG6cYf1cA6vzDi66HIvaki0byZZX95A==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.10", + "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", - "aria-query": "5.3.1", + "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.8.1", + "devalue": "^5.5.0", "esm-env": "^1.2.1", - "esrap": "^2.2.9", + "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -8389,9 +7978,9 @@ } }, "node_modules/systeminformation": { - "version": "5.31.7", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.7.tgz", - "integrity": "sha512-/8NC53e5nP9nmhn42/ncdOkyJnOoue/Vy+tJOyUGd1Yv66G069wK4rrziwhrqDETgk78CudTQupw5z19S5uoZw==", + "version": "5.30.3", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.30.3.tgz", + "integrity": "sha512-NgHJUpA+y7j4asLQa9jgBt+Eb2piyQIXQ+YjOyd2K0cHNwbNJ6I06F5afOqOiaCuV/wrEyGrb0olg4aFLlJD+A==", "license": "MIT", "os": [ "darwin", @@ -8632,6 +8221,13 @@ "integrity": "sha512-k/CjiZ80bYss6Qs7/ex1TBlPD11whT9oKfT8oTGiHa34W4JRd1NiH/Tr1DbHWQ2/vMUypxksLnF2CfmlmM5XFQ==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinycolor2": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", @@ -8639,6 +8235,13 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -8675,9 +8278,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { @@ -8687,6 +8290,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8786,6 +8419,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD" }, "node_modules/tsutils": { @@ -9329,34 +8963,17 @@ } }, "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", "dependencies": { - "content-type": "^2.0.0", + "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.6" } }, "node_modules/typedoc": { @@ -9383,23 +9000,10 @@ "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x" } }, - "node_modules/typedoc-github-theme": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/typedoc-github-theme/-/typedoc-github-theme-0.4.0.tgz", - "integrity": "sha512-lo/hr4EFZxq0SsMGeAscKUzljIKFgrJf5fb4nOAJcqaiSShQv7kzwF6M1s2fVRvUyx6UsmD4zEb+MtKkbucYpg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "typedoc": "~0.28.0" - } - }, "node_modules/typedoc/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9407,13 +9011,13 @@ } }, "node_modules/typedoc/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^2.0.1" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -9451,18 +9055,18 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz", + "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==", "license": "MIT", "engines": { "node": ">=20.18.1" } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" }, @@ -9516,9 +9120,9 @@ } }, "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", "dependencies": { @@ -9590,6 +9194,29 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vite-plugin-singlefile": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.0.tgz", @@ -9608,9 +9235,9 @@ } }, "node_modules/vite-plugin-solid": { - "version": "2.11.12", - "resolved": "https://registry.npmjs.org/vite-plugin-solid/-/vite-plugin-solid-2.11.12.tgz", - "integrity": "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA==", + "version": "2.11.10", + "resolved": "https://registry.npmjs.org/vite-plugin-solid/-/vite-plugin-solid-2.11.10.tgz", + "integrity": "sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw==", "dev": true, "license": "MIT", "dependencies": { @@ -9624,7 +9251,7 @@ "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "peerDependenciesMeta": { "@testing-library/jest-dom": { @@ -9633,9 +9260,9 @@ } }, "node_modules/vite-prerender-plugin": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/vite-prerender-plugin/-/vite-prerender-plugin-0.5.13.tgz", - "integrity": "sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g==", + "version": "0.5.12", + "resolved": "https://registry.npmjs.org/vite-prerender-plugin/-/vite-prerender-plugin-0.5.12.tgz", + "integrity": "sha512-EiwhbMn+flg14EysbLTmZSzq8NGTxhytgK3bf4aGRF1evWLGwZiHiUJ1KZDvbxgKbMf2pG6fJWGEa3UZXOnR1g==", "dev": true, "license": "MIT", "dependencies": { @@ -9647,7 +9274,7 @@ "stack-trace": "^1.0.0-pre2" }, "peerDependencies": { - "vite": "5.x || 6.x || 7.x || 8.x" + "vite": "5.x || 6.x || 7.x" } }, "node_modules/vite/node_modules/fdir": { @@ -9669,9 +9296,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { @@ -9701,17 +9328,103 @@ } } }, - "node_modules/vue": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", - "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.27", - "@vue/compiler-sfc": "3.5.27", - "@vue/runtime-dom": "3.5.27", - "@vue/server-renderer": "3.5.27", - "@vue/shared": "3.5.27" + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vue": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", + "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-sfc": "3.5.26", + "@vue/runtime-dom": "3.5.26", + "@vue/server-renderer": "3.5.26", + "@vue/shared": "3.5.26" }, "peerDependencies": { "typescript": "*" @@ -9735,6 +9448,18 @@ "node": ">=18" } }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/whatwg-mimetype": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", @@ -9759,6 +9484,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/widest-line": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", @@ -9844,9 +9586,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", "bin": { @@ -9904,13 +9646,12 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "dev": true, + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", "license": "ISC", "peerDependencies": { - "zod": "^3.25.28 || ^4" + "zod": "^3.25 || ^4" } } } diff --git a/package.json b/package.json index c86efa9..5cd7bc6 100644 --- a/package.json +++ b/package.json @@ -2,16 +2,13 @@ "name": "@modelcontextprotocol/ext-apps", "repository": { "type": "git", - "url": "git+https://github.com/modelcontextprotocol/ext-apps.git" + "url": "https://github.com/modelcontextprotocol/ext-apps" }, "homepage": "https://github.com/modelcontextprotocol/ext-apps", - "version": "2.0.0", + "version": "0.4.1", "license": "MIT", "description": "MCP Apps SDK โ€” Enable MCP servers to display interactive user interfaces in conversational clients.", "type": "module", - "engines": { - "node": ">=20" - }, "main": "./dist/src/app.js", "exports": { ".": { @@ -47,84 +44,62 @@ "examples/*" ], "scripts": { + "postinstall": "node scripts/setup-bun.mjs || echo 'setup-bun.mjs failed or not available'", "start": "npm run examples:dev", "generate:schemas": "tsx scripts/generate-schemas.ts && prettier --write \"src/generated/**/*\"", - "sync:snippets": "bun scripts/sync-snippets.ts", - "build": "npm run generate:schemas && npm run sync:snippets && node scripts/run-bun.mjs build.bun.ts && node scripts/link-self.mjs", + "build": "npm run generate:schemas && node scripts/run-bun.mjs build.bun.ts", "prepack": "npm run build", "build:all": "npm run examples:build", - "test": "bun test src examples", - "test:dependency-isolation": "npm run build && node scripts/check-dependency-isolation.mjs", + "test": "bun test src", "test:e2e": "playwright test", "test:e2e:update": "playwright test --update-snapshots", "test:e2e:ui": "playwright test --ui", - "test:e2e:docker": "docker run --rm -e EXAMPLE -v $(pwd):/work -w /work -it mcr.microsoft.com/playwright:v1.57.0-noble sh -c 'apt-get update -qq && apt-get install -qq -y python3-venv curl > /dev/null && curl -LsSf https://astral.sh/uv/install.sh | sh && export PATH=\"$HOME/.local/bin:$PATH\" && npm i -g bun && npm ci && npx playwright test'", - "test:e2e:docker:update": "npm run build:all && docker run --rm -e EXAMPLE -v $(pwd):/work -w /work -it mcr.microsoft.com/playwright:v1.57.0-noble sh -c 'apt-get update -qq && apt-get install -qq -y python3-venv curl > /dev/null && curl -LsSf https://astral.sh/uv/install.sh | sh && export PATH=\"$HOME/.local/bin:$PATH\" && npm i -g bun && npm ci && npx playwright test --update-snapshots'", + "test:e2e:docker": "docker run --rm -v $(pwd):/work -w /work -it mcr.microsoft.com/playwright:v1.57.0-noble sh -c 'apt-get update -qq && apt-get install -qq -y python3-venv curl > /dev/null && curl -LsSf https://astral.sh/uv/install.sh | sh && export PATH=\"$HOME/.local/bin:$PATH\" && npm i -g bun && npm ci && npx playwright test'", + "test:e2e:docker:update": "npm run build:all && docker run --rm -v $(pwd):/work -w /work -it mcr.microsoft.com/playwright:v1.57.0-noble sh -c 'apt-get update -qq && apt-get install -qq -y python3-venv curl > /dev/null && curl -LsSf https://astral.sh/uv/install.sh | sh && export PATH=\"$HOME/.local/bin:$PATH\" && npm i -g bun && npm ci && npx playwright test --update-snapshots'", "preexamples:build": "npm run build", "examples:build": "bun examples/run-all.ts build", "examples:start": "NODE_ENV=development npm run build && bun examples/run-all.ts start", "examples:dev": "NODE_ENV=development bun examples/run-all.ts dev", "watch": "nodemon --watch src --ext ts,tsx --exec 'bun build.bun.ts'", - "prepare": "npm run build && husky", + "prepare": "node scripts/setup-bun.mjs && npm run build && husky", "docs": "typedoc", "docs:watch": "typedoc --watch", - "generate:screenshots": "npm run build:all && docker run --rm -e EXAMPLE -e GENERATE_SCREENSHOTS=1 -v $(pwd):/work -w /work mcr.microsoft.com/playwright:v1.57.0-noble sh -c 'apt-get update -qq && apt-get install -qq -y python3-venv curl > /dev/null && curl -LsSf https://astral.sh/uv/install.sh | sh && export PATH=\"$HOME/.local/bin:$PATH\" && npm i -g bun && npm ci && npx playwright test tests/e2e/generate-grid-screenshots.spec.ts'", + "generate:screenshots": "npm run build:all && docker run --rm -v $(pwd):/work -w /work mcr.microsoft.com/playwright:v1.57.0-noble sh -c 'apt-get update -qq && apt-get install -qq -y python3-venv curl > /dev/null && curl -LsSf https://astral.sh/uv/install.sh | sh && export PATH=\"$HOME/.local/bin:$PATH\" && npm i -g bun && npm ci && npx playwright test tests/e2e/generate-grid-screenshots.spec.ts'", "prettier": "prettier -u \"**/*.{js,jsx,ts,tsx,mjs,json,md,yml,yaml}\" --check", "prettier:fix": "prettier -u \"**/*.{js,jsx,ts,tsx,mjs,json,md,yml,yaml}\" --write", - "check:versions": "node scripts/check-versions.mjs", - "bump": "node scripts/bump-version.mjs", - "update-lock:docker": "rm -rf node_modules package-lock.json examples/*/node_modules && docker run --rm --platform linux/amd64 -v $(pwd):/work -w /work -e HOME=/tmp node:latest npm i --registry=https://registry.npmjs.org/ --ignore-scripts && rm -rf node_modules examples/*/node_modules && npm i --registry=https://registry.npmjs.org/" + "check:versions": "node scripts/check-versions.mjs" }, "author": "Olivier Chafik", - "//interop": "The @modelcontextprotocol/sdk 1.x and ext-apps-v1 devDependencies exist only for src/interop-v1.test.ts, which runs the published 1.7.5 package against this build. The library does not use them.", "devDependencies": { - "@boneskull/typedoc-plugin-mermaid": "^0.2.0", - "@modelcontextprotocol/client": "2.0.0", - "@modelcontextprotocol/core": "2.0.0", - "@modelcontextprotocol/sdk": "1.30.0", - "@modelcontextprotocol/server": "2.0.0", - "@playwright/test": "1.57.0", + "@modelcontextprotocol/sdk": "^1.24.0", + "@playwright/test": "^1.52.0", "@types/bun": "^1.3.2", - "@types/node": "20.19.27", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", - "bun": "^1.2.21", - "caniuse-lite": "1.0.30001763", - "cheerio": "1.1.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "cross-env": "^10.1.0", - "electron-to-chromium": "1.5.267", "esbuild": "^0.25.12", "express": "^5.1.0", - "ext-apps-v1": "npm:@modelcontextprotocol/ext-apps@1.7.5", "husky": "^9.1.7", "nodemon": "^3.1.0", - "playwright": "1.57.0", - "playwright-core": "1.57.0", "prettier": "^3.6.2", "react": "^19.2.0", "react-dom": "^19.2.0", - "sharp": "^0.35.3", + "sharp": "^0.34.5", "ts-to-zod": "^5.1.0", "tsx": "^4.21.0", "typedoc": "^0.28.14", - "typedoc-github-theme": "^0.4.0", "typescript": "^5.9.3", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "peerDependencies": { - "@modelcontextprotocol/client": "^2.0.0", - "@modelcontextprotocol/core": "^2.0.0", - "@modelcontextprotocol/server": "^2.0.0", + "@modelcontextprotocol/sdk": "^1.24.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", - "zod": "^4.2.0" + "zod": "^3.25.0 || ^4.0.0" }, "peerDependenciesMeta": { - "@modelcontextprotocol/server": { - "optional": true - }, "react": { "optional": true }, @@ -132,13 +107,23 @@ "optional": true } }, - "overrides": { - "seroval": "1.4.1", - "seroval-plugins": "1.4.2", - "solid-js": "1.9.10", - "@types/node": "20.19.27" - }, - "dependencies": { - "@standard-schema/spec": "^1.1.0" + "optionalDependencies": { + "@oven/bun-darwin-aarch64": "^1.2.21", + "@oven/bun-darwin-x64": "^1.2.21", + "@oven/bun-darwin-x64-baseline": "^1.2.21", + "@oven/bun-linux-aarch64": "^1.2.21", + "@oven/bun-linux-aarch64-musl": "^1.2.21", + "@oven/bun-linux-x64": "^1.2.21", + "@oven/bun-linux-x64-baseline": "^1.2.21", + "@oven/bun-linux-x64-musl": "^1.2.21", + "@oven/bun-linux-x64-musl-baseline": "^1.2.21", + "@oven/bun-windows-x64": "^1.2.21", + "@oven/bun-windows-x64-baseline": "^1.2.21", + "@rollup/rollup-darwin-arm64": "^4.53.3", + "@rollup/rollup-darwin-x64": "^4.53.3", + "@rollup/rollup-linux-arm64-gnu": "^4.53.3", + "@rollup/rollup-linux-x64-gnu": "^4.53.3", + "@rollup/rollup-win32-arm64-msvc": "^4.53.3", + "@rollup/rollup-win32-x64-msvc": "^4.53.3" } } diff --git a/playwright.config.ts b/playwright.config.ts index ecf1e13..0b450b8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,17 +2,10 @@ import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "./tests/e2e", - // Exclude the screenshot generation spec from default runs. - // It writes examples/*/grid-cell.png as a side effect and is meant to be - // invoked only via `npm run generate:screenshots` (which sets - // GENERATE_SCREENSHOTS=1 to bypass this ignore). - testIgnore: process.env.GENERATE_SCREENSHOTS - ? [] - : ["**/generate-grid-screenshots.spec.ts"], fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 2 : 16, // Parallel execution now works with factory pattern + workers: process.env.CI ? 2 : 4, // Parallel execution now works with factory pattern timeout: 30000, // 30s per test reporter: process.env.CI ? "list" : "html", // Use platform-agnostic snapshot names (no -darwin/-linux suffix) @@ -30,12 +23,10 @@ export default defineConfig({ ...devices["Desktop Chrome"], // Use default Chromium everywhere for consistent screenshot rendering // Run `npm run test:e2e:docker` locally for CI-identical results - ...(process.env.PW_CHANNEL ? { channel: process.env.PW_CHANNEL } : {}), }, }, ], // Run examples server before tests - // Supports EXAMPLE= env var to run a single example (e.g., EXAMPLE=say-server npm run test:e2e) webServer: { command: "npm run examples:start", url: "http://localhost:8080", @@ -43,15 +34,6 @@ export default defineConfig({ reuseExistingServer: false, // 3 minutes to allow uv to download Python dependencies on first run timeout: 180000, - // Pass through EXAMPLE env var to filter to a single server - env: { - ...process.env, - EXAMPLE: process.env.EXAMPLE ?? "", - // Let pdf-server fetch from the http://127.0.0.1 range-counting fixture - // (validateUrl rejects loopback HTTP unless this is set). Scoped to this - // server's check only โ€” does not touch Node's TLS verification. - PDF_SERVER_ALLOW_LOOPBACK_HTTP: "1", - }, }, // Snapshot configuration expect: { diff --git a/plugins/mcp-apps/.claude-plugin/plugin.json b/plugins/mcp-apps/.claude-plugin/plugin.json index 958f109..0f8030c 100644 --- a/plugins/mcp-apps/.claude-plugin/plugin.json +++ b/plugins/mcp-apps/.claude-plugin/plugin.json @@ -5,7 +5,7 @@ "author": { "name": "MCP Apps" }, - "repository": "https://github.com/modelcontextprotocol/ext-apps", + "repository": "https://github.com/anthropics/mcp-ext-apps", "license": "MIT", "keywords": ["mcp", "mcp-apps", "model-context-protocol", "ui", "interactive"] } diff --git a/plugins/mcp-apps/README.md b/plugins/mcp-apps/README.md index 541495c..ff56f21 100644 --- a/plugins/mcp-apps/README.md +++ b/plugins/mcp-apps/README.md @@ -1,29 +1,20 @@ # MCP Apps Plugin for Claude Code -A Claude Code plugin that provides skills for building MCP Apps. +A Claude Code plugin that provides the "Create MCP App" skill for building MCP Apps with interactive UIs. ## Installation Install via Claude Code: -``` -/plugin marketplace add modelcontextprotocol/ext-apps -/plugin install mcp-apps@modelcontextprotocol-ext-apps -``` +1. Run `/plugin` in Claude Code +2. Navigate to the Discover tab +3. Install "mcp-apps" ## Usage -Invoke skills by asking Claude Code to: - -### Create MCP App +Invoke the skill by asking Claude Code to: - "Create an MCP App" - "Add a UI to an MCP tool" -- "Build an interactive MCP View" +- "Build an interactive MCP widget" - "Scaffold an MCP App" - -### Migrate from OpenAI - -- "Migrate from OpenAI Apps SDK" -- "Convert OpenAI App to MCP" -- "Port from window.openai" diff --git a/plugins/mcp-apps/skills/add-app-to-server/SKILL.md b/plugins/mcp-apps/skills/add-app-to-server/SKILL.md deleted file mode 100644 index 5a473f0..0000000 --- a/plugins/mcp-apps/skills/add-app-to-server/SKILL.md +++ /dev/null @@ -1,431 +0,0 @@ ---- -name: add-app-to-server -description: This skill should be used when the user asks to "add an app to my MCP server", "add UI to my MCP server", "add a view to my MCP tool", "enrich MCP tools with UI", "add interactive UI to existing server", "add MCP Apps to my server", or needs to add interactive UI capabilities to an existing MCP server that already has tools. Provides guidance for analyzing existing tools and adding MCP Apps UI resources. ---- - -# Add UI to MCP Server - -Enrich an existing MCP server's tools with interactive UIs using the MCP Apps SDK (`@modelcontextprotocol/ext-apps`). - -## How It Works - -Existing tools get paired with HTML resources that render inline in the host's conversation. The tool continues to work for text-only clients โ€” UI is an enhancement, not a replacement. Each tool that benefits from UI gets linked to a resource via `_meta.ui.resourceUri`, and the host renders that resource in a sandboxed iframe when the tool is called. - -## Getting Reference Code - -Clone the SDK repository for working examples and API documentation: - -```bash -git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-apps -``` - -### API Reference (Source Files) - -Read JSDoc documentation directly from `/tmp/mcp-ext-apps/src/`: - -| File | Contents | -|------|----------| -| `src/app.ts` | `App` class, handlers (`ontoolinput`, `ontoolresult`, `onhostcontextchanged`, `onteardown`), lifecycle | -| `src/server/index.ts` | `registerAppTool`, `registerAppResource`, `getUiCapability`, tool visibility options | -| `src/spec.types.ts` | All type definitions: `McpUiHostContext`, CSS variable keys, display modes | -| `src/styles.ts` | `applyDocumentTheme`, `applyHostStyleVariables`, `applyHostFonts` | -| `src/react/useApp.tsx` | `useApp` hook for React apps | -| `src/react/useHostStyles.ts` | `useHostStyles`, `useHostStyleVariables`, `useHostFonts` hooks | - -### Key Examples (Mixed Tool Patterns) - -These examples demonstrate servers with both App-enhanced and plain tools โ€” the exact pattern you're adding: - -| Example | Pattern | -|---------|---------| -| `examples/map-server/` | `show-map` (App tool) + `geocode` (plain tool) | -| `examples/pdf-server/` | `display_pdf` (App tool) + `list_pdfs` (plain tool) + `read_pdf_bytes` (app-only tool) | -| `examples/system-monitor-server/` | `get-system-info` (App tool) + `poll-system-stats` (app-only polling tool) | - -### Framework Templates - -Learn and adapt from `/tmp/mcp-ext-apps/examples/basic-server-{framework}/`: - -| Template | Key Files | -|----------|-----------| -| `basic-server-vanillajs/` | `server.ts`, `src/mcp-app.ts`, `mcp-app.html` | -| `basic-server-react/` | `server.ts`, `src/mcp-app.tsx` (uses `useApp` hook) | -| `basic-server-vue/` | `server.ts`, `src/App.vue` | -| `basic-server-svelte/` | `server.ts`, `src/App.svelte` | -| `basic-server-preact/` | `server.ts`, `src/mcp-app.tsx` | -| `basic-server-solid/` | `server.ts`, `src/mcp-app.tsx` | - -## Step 1: Analyze Existing Tools - -Before writing any code, analyze the server's existing tools and determine which ones benefit from UI. - -1. Read the server source and list all registered tools -2. For each tool, assess whether it would benefit from UI (returns data that could be visualized, involves user interaction, etc.) vs. is fine as text-only (simple lookups, utility functions) -3. Identify tools that could become **app-only helpers** (data the UI needs to poll/fetch but the model doesn't need to call directly) -4. Present the analysis to the user and confirm which tools to enhance - -### Decision Framework - -| Tool output type | UI benefit | Example | -|---|---|---| -| Structured data / lists / tables | High โ€” interactive table, search, filtering | List of items, search results | -| Metrics / numbers over time | High โ€” charts, gauges, dashboards | System stats, analytics | -| Media / rich content | High โ€” viewer, player, renderer | Maps, PDFs, images, video | -| Simple text / confirmations | Low โ€” text is fine | "File created", "Setting updated" | -| Data for other tools | Consider app-only | Polling endpoints, chunk loaders | - -## Step 2: Add Dependencies - -```bash -npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/client@^2.0.0 @modelcontextprotocol/server@^2.0.0 zod@^4.2.0 -npm install -D vite vite-plugin-singlefile -``` - -Plus framework-specific dependencies if needed (e.g., `react`, `react-dom`, `@vitejs/plugin-react` for React), and `@modelcontextprotocol/node` / `@modelcontextprotocol/express` if the server uses HTTP transports. - -ext-apps 2.x requires the split base MCP SDK packages at `^2.0.0` -(`@modelcontextprotocol/core` comes in transitively). Existing servers that -still import `@modelcontextprotocol/sdk` v1 must migrate those imports and -handler schemas before adding the App integration: - -| SDK v1 (`@modelcontextprotocol/sdk`) | SDK v2 | -|---|---| -| `sdk/server/mcp.js` (`McpServer`) | `@modelcontextprotocol/server` | -| `sdk/server/streamableHttp.js` (`StreamableHTTPServerTransport`) | `NodeStreamableHTTPServerTransport` from `@modelcontextprotocol/node` | -| Express wiring by hand | `createMcpExpressApp` from `@modelcontextprotocol/express` | -| `sdk/server/stdio.js` | `@modelcontextprotocol/server/stdio` | -| `sdk/types.js` types (`CallToolResult`, โ€ฆ) | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | -| `sdk/types.js` zod schemas (`CallToolResultSchema`, โ€ฆ) | `@modelcontextprotocol/core` | -| Raw zod shapes: `inputSchema: { q: z.string() }` | `inputSchema: z.object({ q: z.string() })` | -| `extra.signal` in tool callbacks | `extra.mcpReq.signal` | -| `setRequestHandler(SomeRequestSchema, handler)` | `setRequestHandler("some/method", { params: ParamsSchema }, handler)` (the 2-arg form is only for spec methods such as `"tools/call"`) | - -## Step 3: Set Up the Build Pipeline - -### Vite Configuration - -Create `vite.config.ts` with `vite-plugin-singlefile` to bundle the UI into a single HTML file: - -```typescript -import { defineConfig } from "vite"; -import { viteSingleFile } from "vite-plugin-singlefile"; - -export default defineConfig({ - plugins: [viteSingleFile()], - build: { - outDir: "dist", - rollupOptions: { - input: "mcp-app.html", // one per UI, or one shared entry - }, - }, -}); -``` - -### HTML Entry Point - -Create `mcp-app.html` (or one per distinct UI if tools need different views): - -```html - - - - - - MCP App - - -
- - - -``` - -### Build Scripts - -Add build scripts to `package.json`. The UI must be built before the server code bundles it: - -```json -{ - "scripts": { - "build:ui": "vite build", - "build:server": "tsc", - "build": "npm run build:ui && npm run build:server", - "serve": "tsx server.ts" - } -} -``` - -## Step 4: Convert Tools to App Tools - -Transform plain MCP tools into App tools with UI. - -**Before** (plain MCP tool): -```typescript -server.registerTool( - "my-tool", - { inputSchema: z.object({ param: z.string() }) }, - async ({ param }) => { - const data = await fetchData(param); - return { content: [{ type: "text", text: JSON.stringify(data) }] }; - }, -); -``` - -**After** (App tool with UI): -```typescript -import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; - -const resourceUri = "ui://my-tool/mcp-app.html"; - -registerAppTool( - server, - "my-tool", - { - description: "Shows data with an interactive UI", - inputSchema: z.object({ param: z.string() }), - _meta: { ui: { resourceUri } }, - }, - async ({ param }) => { - const data = await fetchData(param); - return { - content: [{ type: "text", text: JSON.stringify(data) }], // text fallback for non-UI hosts - structuredContent: { data }, // structured data for the UI - }; - }, -); -``` - -Key guidance: -- **Always keep the `content` array** with a text fallback for text-only clients -- Add `structuredContent` for data the UI needs to render -- Link the tool to its resource via `_meta.ui.resourceUri` -- Leave tools that don't benefit from UI unchanged โ€” they stay as plain tools - -## Step 5: Register Resources - -Register the HTML resource so the host can fetch it: - -```typescript -import fs from "node:fs/promises"; -import path from "node:path"; - -const resourceUri = "ui://my-tool/mcp-app.html"; - -registerAppResource( - server, - "My Tool UI", - resourceUri, - {}, - async () => { - const html = await fs.readFile( - path.resolve(import.meta.dirname, "dist", "mcp-app.html"), - "utf-8", - ); - return { - contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }], - }; - }, -); -``` - -If multiple tools share the same UI, they can reference the same `resourceUri` and the same resource registration. - -## Step 6: Build the UI - -### Handler Registration - -Register ALL handlers BEFORE calling `app.connect()`: - -```typescript -import { App, PostMessageTransport, applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps"; - -const app = new App({ name: "My App", version: "1.0.0" }); - -app.ontoolinput = (params) => { - // Render the UI using params.arguments and/or params.structuredContent -}; - -app.ontoolresult = (result) => { - // Update UI with final tool result -}; - -app.onhostcontextchanged = (ctx) => { - 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) { - const { top, right, bottom, left } = ctx.safeAreaInsets; - document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`; - } -}; - -app.onteardown = async () => { - return {}; -}; - -await app.connect(new PostMessageTransport()); -``` - -### Host Styling - -Use host CSS variables for theme integration: - -```css -.container { - background: var(--color-background-secondary); - color: var(--color-text-primary); - font-family: var(--font-sans); - border-radius: var(--border-radius-md); -} -``` - -Key variable groups: `--color-background-*`, `--color-text-*`, `--color-border-*`, `--font-sans`, `--font-mono`, `--font-text-*-size`, `--font-heading-*-size`, `--border-radius-*`. See `src/spec.types.ts` for the full list. - -For React apps, use the `useApp` and `useHostStyles` hooks instead โ€” see `basic-server-react/` for the pattern. - -## Optional Enhancements - -### App-Only Helper Tools - -Tools the UI calls but the model doesn't need to invoke directly (polling, pagination, chunk loading): - -```typescript -registerAppTool(server, "poll-data", { - description: "Polls latest data for the UI", - _meta: { ui: { resourceUri, visibility: ["app"] } }, -}, async () => { - const data = await getLatestData(); - return { content: [{ type: "text", text: JSON.stringify(data) }] }; -}); -``` - -The UI calls these via -`app.callServerTool({ name: "poll-data", arguments: {} })`. - -### CSP Configuration - -If the UI needs to load external resources (fonts, APIs, CDNs), declare the domains: - -```typescript -registerAppResource(server, "My Tool UI", resourceUri, {}, async () => ({ - contents: [ - { - uri: resourceUri, - mimeType: RESOURCE_MIME_TYPE, - text: html, - _meta: { - ui: { - csp: { - connectDomains: ["https://api.example.com"], // fetch/XHR targets - resourceDomains: ["https://cdn.example.com"], // scripts, styles, images - frameDomains: ["https://embed.example.com"], // nested iframes - }, - }, - }, - }, - ], -})); -``` - -### Streaming Partial Input - -For large tool inputs, show progress during LLM generation: - -```typescript -app.ontoolinputpartial = (params) => { - const args = params.arguments; // Healed partial JSON - always valid - // Render preview with partial data -}; - -app.ontoolinput = (params) => { - // Final complete input - switch to full render -}; -``` - -### Graceful Degradation with `getUiCapability()` - -Conditionally register App tools only when the client supports UI, falling back to text-only tools: - -```typescript -import { getUiCapability, registerAppTool, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; - -server.server.oninitialized = () => { - const clientCapabilities = server.server.getClientCapabilities(); - const uiCap = getUiCapability(clientCapabilities); - - if (uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) { - // Client supports UI โ€” register App tool - registerAppTool(server, "my-tool", { - description: "Shows data with interactive UI", - _meta: { ui: { resourceUri } }, - }, appToolHandler); - } else { - // Text-only client โ€” register plain tool - server.registerTool( - "my-tool", - { - description: "Shows data", - inputSchema: z.object({ param: z.string() }), - }, - plainToolHandler, - ); - } -}; -``` - -### Fullscreen Mode - -Allow the UI to expand to fullscreen: - -```typescript -app.onhostcontextchanged = (ctx) => { - if (ctx.availableDisplayModes?.includes("fullscreen")) { - fullscreenBtn.style.display = "block"; - } - if (ctx.displayMode) { - container.classList.toggle("fullscreen", ctx.displayMode === "fullscreen"); - } -}; - -async function toggleFullscreen() { - const newMode = currentMode === "fullscreen" ? "inline" : "fullscreen"; - const result = await app.requestDisplayMode({ mode: newMode }); - currentMode = result.mode; -} -``` - -## Common Mistakes to Avoid - -1. **Forgetting text `content` fallback** โ€” Always include `content` array with text for non-UI hosts -2. **Registering handlers after `connect()`** โ€” Register ALL handlers BEFORE calling `app.connect()` -3. **Missing `vite-plugin-singlefile`** โ€” Without it, assets won't load in the sandboxed iframe -4. **Forgetting resource registration** โ€” The tool references a `resourceUri` that must have a matching resource -5. **Hardcoding styles** โ€” Use host CSS variables (`var(--color-*)`) for theme integration -6. **Not handling safe area insets** โ€” Always apply `ctx.safeAreaInsets` in `onhostcontextchanged` - -## Testing - -### Using basic-host - -Test the enhanced server with the basic-host example: - -```bash -# Terminal 1: Build and run your server -npm run build && npm run serve - -# Terminal 2: Run basic-host (from cloned repo) -cd /tmp/mcp-ext-apps/examples/basic-host -npm install -SERVERS='["http://localhost:3001/mcp"]' npm run start -# Open http://localhost:8080 -``` - -Configure `SERVERS` with a JSON array of your server URLs (default: `http://localhost:3001/mcp`). - -### Verify - -1. Plain tools still work and return text output -2. App tools render their UI in the iframe -3. `ontoolinput` handler fires with tool arguments -4. `ontoolresult` handler fires with tool result -5. Host styling (theme, fonts, colors) applies correctly diff --git a/plugins/mcp-apps/skills/convert-web-app/SKILL.md b/plugins/mcp-apps/skills/convert-web-app/SKILL.md deleted file mode 100644 index 8fb7fa0..0000000 --- a/plugins/mcp-apps/skills/convert-web-app/SKILL.md +++ /dev/null @@ -1,544 +0,0 @@ ---- -name: convert-web-app -description: This skill should be used when the user asks to "add MCP App support to my web app", "turn my web app into a hybrid MCP App", "make my web page work as an MCP App too", "wrap my existing UI as an MCP App", "convert iframe embed to MCP App", "turn my SPA into an MCP App", or needs to add MCP App support to an existing web application while keeping it working standalone. Provides guidance for analyzing existing web apps and creating a hybrid web + MCP App with server-side tool and resource registration. ---- - -# Add MCP App Support to a Web App - -Add MCP App support to an existing web application so it works both as a standalone web app **and** as an MCP App that renders inline in MCP-enabled hosts like Claude Desktop โ€” from a single codebase. - -## How It Works - -The existing web app stays intact. A thin initialization layer detects whether the app is running inside an MCP host or as a regular web page, and fetches parameters from the appropriate source. A new MCP server wraps the app's bundled HTML as a resource and registers a tool to display it. - -``` -Standalone: Browser loads page โ†’ App reads URL params / APIs โ†’ renders -MCP App: Host calls tool โ†’ Server returns result โ†’ Host renders app in iframe โ†’ App reads MCP lifecycle โ†’ renders -``` - -The app's rendering logic is shared โ€” only the data source changes. - -## Getting Reference Code - -Clone the SDK repository for working examples and API documentation: - -```bash -git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-apps -``` - -### API Reference (Source Files) - -Read JSDoc documentation directly from `/tmp/mcp-ext-apps/src/`: - -| File | Contents | -|------|----------| -| `src/app.ts` | `App` class, handlers (`ontoolinput`, `ontoolresult`, `onhostcontextchanged`, `onteardown`), lifecycle | -| `src/server/index.ts` | `registerAppTool`, `registerAppResource`, tool visibility options | -| `src/spec.types.ts` | All type definitions: `McpUiHostContext`, CSS variable keys, display modes | -| `src/styles.ts` | `applyDocumentTheme`, `applyHostStyleVariables`, `applyHostFonts` | -| `src/react/useApp.tsx` | `useApp` hook for React apps | -| `src/react/useHostStyles.ts` | `useHostStyles`, `useHostStyleVariables`, `useHostFonts` hooks | - -### Framework Templates - -Learn and adapt from `/tmp/mcp-ext-apps/examples/basic-server-{framework}/`: - -| Template | Key Files | -|----------|-----------| -| `basic-server-vanillajs/` | `server.ts`, `src/mcp-app.ts`, `mcp-app.html` | -| `basic-server-react/` | `server.ts`, `src/mcp-app.tsx` (uses `useApp` hook) | -| `basic-server-vue/` | `server.ts`, `src/App.vue` | -| `basic-server-svelte/` | `server.ts`, `src/App.svelte` | -| `basic-server-preact/` | `server.ts`, `src/mcp-app.tsx` | -| `basic-server-solid/` | `server.ts`, `src/mcp-app.tsx` | - -### Reference Examples - -| Example | Relevant Pattern | -|---------|-----------------| -| `examples/map-server/` | External API integration + CSP (`connectDomains`, `resourceDomains`) | -| `examples/sheet-music-server/` | Library that loads external assets (soundfonts) | -| `examples/pdf-server/` | Binary content handling + app-only helper tools | - -## Step 1: Analyze the Existing Web App - -Before writing any code, examine the existing web app to plan what needs to change. - -### What to Investigate - -1. **Data sources** โ€” How does the app get its data? (URL params, API calls, props, hardcoded, localStorage) -2. **External dependencies** โ€” CDN scripts, fonts, API endpoints, iframe embeds, WebSocket connections -3. **Build system** โ€” Current bundler (Webpack, Vite, Rollup, none), framework (React, Vue, vanilla), entry points -4. **User interactions** โ€” Does the app have inputs/forms that should map to tool parameters? -5. **Runtime detection** โ€” How to tell if the app is running inside an MCP host (e.g., check the current origin, a query param, or whether `window.parent !== window`) - -Present findings to the user and confirm the approach. - -### Data Source Mapping - -In hybrid mode, the app keeps its existing data sources for standalone use and adds MCP equivalents: - -| Standalone data source | MCP App equivalent | -|---|---| -| URL query parameters | `ontoolinput` / `ontoolresult` `arguments` or `structuredContent` | -| REST API calls | `app.callServerTool()` to server-side tools, or keep direct API calls with CSP `connectDomains` | -| Props / component inputs | `ontoolinput` `arguments` | -| localStorage / sessionStorage | Not available in sandboxed iframe โ€” pass via `structuredContent` or server-side state | -| WebSocket connections | Keep with CSP `connectDomains`, or convert to polling via app-only tools | -| Hardcoded data | Move to tool `structuredContent` to make it dynamic | - -## Step 2: Investigate CSP Requirements - -MCP Apps HTML runs in a sandboxed iframe with no same-origin server. **Every** external origin must be declared in CSP โ€” missing origins fail silently. - -**Before writing any code**, build the app and investigate all origins it references: - -1. Build the app using the existing build command -2. Search the resulting HTML, CSS, and JS for **every** origin (not just "external" origins โ€” every network request will need CSP approval) -3. For each origin found, trace back to source: - - If it comes from a constant โ†’ universal (same in dev and prod) - - If it comes from an env var or conditional โ†’ note the mechanism and identify both dev and prod values -4. Check for third-party libraries that may make their own requests (analytics, error tracking, etc.) - -**Document your findings** as three lists, and note for each origin whether it's universal, dev-only, or prod-only: - -- **resourceDomains**: origins serving images, fonts, styles, scripts -- **connectDomains**: origins for API/fetch requests -- **frameDomains**: origins for nested iframes - -If no origins are found, the app may not need custom CSP domains. - -## Step 3: Set Up the MCP Server - -Create a new MCP server with tool and resource registration. This wraps the existing web app for MCP hosts. - -### Dependencies - -```bash -npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/client@^2.0.0 @modelcontextprotocol/server@^2.0.0 zod@^4.2.0 -npm install -D tsx vite vite-plugin-singlefile -``` - -Use `npm install` so the package manager resolves versions; ext-apps 2.x needs -the split base MCP SDK packages at `^2.0.0` (`@modelcontextprotocol/core` comes -in transitively). Do not add the legacy `@modelcontextprotocol/sdk` v1 package -or substitute unpublished local packages. - -### Server Code - -Create `server.ts`: - -```typescript -import { McpServer } from "@modelcontextprotocol/server"; -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; -import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { z } from "zod"; - -const server = new McpServer({ name: "my-app", version: "1.0.0" }); - -const resourceUri = "ui://my-app/mcp-app.html"; - -// Register the tool โ€” inputSchema maps to the app's data sources -registerAppTool(server, "show-app", { - description: "Displays the app with the given parameters", - inputSchema: z.object({ - query: z.string().describe("The search query"), - }), - _meta: { ui: { resourceUri } }, -}, async (args) => { - // Process args server-side if needed - return { - content: [{ type: "text", text: `Showing app for: ${args.query}` }], - structuredContent: { query: args.query }, - }; -}); - -// Register the HTML resource -registerAppResource( - server, - "My App UI", - resourceUri, - {}, - async () => { - const html = await fs.readFile( - path.resolve(import.meta.dirname, "dist", "mcp-app.html"), - "utf-8", - ); - return { - contents: [ - { - uri: resourceUri, - mimeType: RESOURCE_MIME_TYPE, - text: html, - // Add CSP domains from Step 2 if needed: - // _meta: { ui: { csp: { connectDomains: ["https://api.example.com"], resourceDomains: ["https://cdn.example.com"] } } }, - }, - ], - }; - }, -); - -// Start the server -const transport = new StdioServerTransport(); -await server.connect(transport); -``` - -### Package Scripts - -Add to `package.json`: - -```json -{ - "scripts": { - "build:ui": "vite build", - "build:server": "tsc", - "build": "npm run build:ui && npm run build:server", - "serve": "tsx server.ts" - } -} -``` - -## Step 4: Adapt the Build Pipeline - -The MCP App build must produce a single HTML file using `vite-plugin-singlefile`. The standalone web app build stays unchanged. - -### Vite Configuration - -Create or update `vite.config.ts`. If the app already uses Vite, add `vite-plugin-singlefile` and a separate entry point for the MCP App build. If it uses another bundler, add a Vite config alongside for the MCP App build only. - -```typescript -import { defineConfig } from "vite"; -import { viteSingleFile } from "vite-plugin-singlefile"; - -export default defineConfig({ - plugins: [viteSingleFile()], - build: { - outDir: "dist", - rollupOptions: { - input: "mcp-app.html", - }, - }, -}); -``` - -Add framework-specific Vite plugins as needed (e.g., `@vitejs/plugin-react` for React, `@vitejs/plugin-vue` for Vue). - -### HTML Entry Point - -Create `mcp-app.html` as a separate entry point for the MCP App build. This can point to the same app code โ€” the runtime detection handles the rest: - -```html - - - - - - MCP App - - -
- - - -``` - -### Two-Phase Build - -1. Vite bundles the UI โ†’ `dist/mcp-app.html` (single file with all assets inlined) -2. Server is compiled separately (TypeScript โ†’ JavaScript) - -The standalone web app continues to build and deploy as before. - -## Step 5: Add MCP App Initialization Alongside Existing Logic - -This is the core step. Instead of replacing the app's data sources, add an alternative initialization path for MCP mode. The app detects its environment at startup and reads parameters from the right source. - -### The Hybrid Pattern - -```typescript -import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps"; - -// Detect whether we're running inside an MCP host. -// Choose a detection method that fits the app: -// - Origin check: window.location.origin !== 'https://myhost.com' -// - Null origin (sandboxed iframe): window.location.origin === 'null' -// - Query param: new URL(location.href).searchParams.has('mcp') -const isMcpApp = window.location.origin === "null"; - -async function getParameters(): Promise> { - if (isMcpApp) { - // Running as MCP App โ€” get params from tool lifecycle - const app = new App({ name: "My App", version: "1.0.0" }); - - // Register handlers BEFORE connect() - const params = await new Promise>((resolve) => { - app.ontoolresult = (result) => resolve(result.structuredContent ?? {}); - }); - - await app.connect(new PostMessageTransport()); - return params; - } else { - // Running as standalone web app โ€” get params from URL - return Object.fromEntries(new URL(location.href).searchParams); - } -} - -async function main() { - const params = await getParameters(); - renderApp(params); // Same rendering logic for both modes -} - -main().catch(console.error); -``` - -### URL Parameters (Hybrid) - -```typescript -// Before (standalone only): -const query = new URL(location.href).searchParams.get("q"); -renderApp(query); - -// After (hybrid): -async function getQuery(): Promise { - if (isMcpApp) { - const app = new App({ name: "My App", version: "1.0.0" }); - return new Promise((resolve) => { - app.ontoolinput = (params) => resolve(params.arguments?.q ?? ""); - app.connect(new PostMessageTransport()); - }); - } - return new URL(location.href).searchParams.get("q") ?? ""; -} - -const query = await getQuery(); -renderApp(query); // Unchanged rendering logic -``` - -### API Calls (Hybrid) - -```typescript -// Before (standalone only): -const data = await fetch("/api/data").then(r => r.json()); - -// After (hybrid): -async function fetchData(): Promise { - if (isMcpApp) { - const result = await app.callServerTool({ - name: "fetch-data", - arguments: {}, - }); - return result.structuredContent; - } - return fetch("/api/data").then(r => r.json()); -} -``` - -Or keep direct API calls in both modes with CSP `connectDomains`: - -```typescript -// API calls can stay unchanged if the API is external and the CSP declares the domain -// Declare connectDomains: ["api.example.com"] in the resource registration -``` - -### localStorage / sessionStorage (Hybrid) - -```typescript -// Before (standalone only): -const saved = localStorage.getItem("settings"); - -// After (hybrid) โ€” localStorage isn't available in sandboxed iframes: -function getSettings(): any { - if (isMcpApp) { - // Will be provided via tool result - return null; // or a default - } - return JSON.parse(localStorage.getItem("settings") ?? "null"); -} -``` - -### Complete Hybrid Example - -```typescript -import { App, PostMessageTransport, applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps"; - -const isMcpApp = window.location.origin === "null"; - -async function initMcpApp(): Promise> { - const app = new App({ name: "My App", version: "1.0.0" }); - - // Register ALL handlers BEFORE connect() - const params = await new Promise>((resolve) => { - app.ontoolinput = (input) => resolve(input.arguments ?? {}); - }); - - app.onhostcontextchanged = (ctx) => { - 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) { - const { top, right, bottom, left } = ctx.safeAreaInsets; - document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`; - } - }; - - app.onteardown = async () => { - return {}; - }; - - await app.connect(new PostMessageTransport()); - return params; -} - -async function initStandaloneApp(): Promise> { - return Object.fromEntries(new URL(location.href).searchParams); -} - -async function main() { - const params = isMcpApp ? await initMcpApp() : await initStandaloneApp(); - renderApp(params); // Same rendering logic โ€” no fork needed -} - -main().catch(console.error); -``` - -## Step 6: Add Host Styling Integration (MCP Mode Only) - -When running as an MCP App, integrate with host styling for theme consistency. Use CSS variable fallbacks so the app looks correct in both modes. - -**Vanilla JS** โ€” use helper functions: -```typescript -import { applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps"; - -app.onhostcontextchanged = (ctx) => { - if (ctx.theme) applyDocumentTheme(ctx.theme); - if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables); - if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts); -}; -``` - -**React** โ€” use hooks: -```typescript -import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react"; - -const { app } = useApp({ appInfo, capabilities, onAppCreated }); -useHostStyles(app); -``` - -**Using variables in CSS** โ€” use `var()` with fallbacks so standalone mode still looks right: - -```css -.container { - background: var(--color-background-secondary, #f5f5f5); - color: var(--color-text-primary, #333); - font-family: var(--font-sans, system-ui); - border-radius: var(--border-radius-md, 8px); -} -``` - -Key variable groups: `--color-background-*`, `--color-text-*`, `--color-border-*`, `--font-sans`, `--font-mono`, `--font-text-*-size`, `--font-heading-*-size`, `--border-radius-*`. See `src/spec.types.ts` for the full list. - -## Optional Enhancements - -### App-Only Helper Tools - -For data the UI needs to poll or fetch that the model doesn't need to call directly: - -```typescript -registerAppTool(server, "refresh-data", { - description: "Fetches latest data for the UI", - _meta: { ui: { resourceUri, visibility: ["app"] } }, -}, async () => { - const data = await getLatestData(); - return { content: [{ type: "text", text: JSON.stringify(data) }] }; -}); -``` - -The UI calls these via -`app.callServerTool({ name: "refresh-data", arguments: {} })`. - -### Streaming Partial Input - -For large tool inputs, use `ontoolinputpartial` to show progress during LLM generation: - -```typescript -app.ontoolinputpartial = (params) => { - const args = params.arguments; // Healed partial JSON - always valid - renderPreview(args); -}; - -app.ontoolinput = (params) => { - renderFull(params.arguments); -}; -``` - -### Fullscreen Mode - -```typescript -app.onhostcontextchanged = (ctx) => { - if (ctx.availableDisplayModes?.includes("fullscreen")) { - fullscreenBtn.style.display = "block"; - } - if (ctx.displayMode) { - container.classList.toggle("fullscreen", ctx.displayMode === "fullscreen"); - } -}; - -async function toggleFullscreen() { - const newMode = currentMode === "fullscreen" ? "inline" : "fullscreen"; - const result = await app.requestDisplayMode({ mode: newMode }); - currentMode = result.mode; -} -``` - -### Text Fallback - -Always provide a `content` array for non-UI hosts: - -```typescript -return { - content: [{ type: "text", text: "Fallback description of the result" }], - structuredContent: { /* data for the UI */ }, -}; -``` - -## Common Mistakes to Avoid - -1. **Forgetting CSP declarations for external origins** โ€” fails silently in the sandboxed iframe -2. **Using `localStorage` / `sessionStorage` in MCP mode** โ€” not available in sandboxed iframe; use fallbacks or pass via `structuredContent` -3. **Missing `vite-plugin-singlefile`** โ€” external assets won't load in the iframe -4. **Registering handlers after `connect()`** โ€” register ALL handlers BEFORE calling `app.connect()` -5. **Hardcoding styles without fallbacks** โ€” use host CSS variables with `var(..., fallback)` so both modes look correct -6. **Not handling safe area insets** โ€” always apply `ctx.safeAreaInsets` in `onhostcontextchanged` -7. **Forgetting text `content` fallback** โ€” always provide `content` array for non-UI hosts -8. **Forgetting resource registration** โ€” the tool references a `resourceUri` that must have a matching resource -9. **Replacing standalone logic instead of branching** โ€” keep the original data sources intact; add the MCP path alongside them - -## Testing - -### Using basic-host - -Test the MCP App mode with the basic-host example: - -```bash -# Terminal 1: Build and run your server -npm run build && npm run serve - -# Terminal 2: Run basic-host (from cloned repo) -cd /tmp/mcp-ext-apps/examples/basic-host -npm install -SERVERS='["http://localhost:3001/mcp"]' npm run start -# Open http://localhost:8080 -``` - -Configure `SERVERS` with a JSON array of your server URLs (default: `http://localhost:3001/mcp`). - -### Verify - -1. **MCP mode**: App loads in basic-host without console errors -2. `ontoolinput` handler fires with tool arguments -3. `ontoolresult` handler fires with tool result -4. Host styling (theme, fonts, colors) applies correctly -5. External resources load (if CSP domains are configured) -6. **Standalone mode**: App still works when opened directly in a browser diff --git a/plugins/mcp-apps/skills/create-mcp-app/SKILL.md b/plugins/mcp-apps/skills/create-mcp-app/SKILL.md index d73dc90..8120ab2 100644 --- a/plugins/mcp-apps/skills/create-mcp-app/SKILL.md +++ b/plugins/mcp-apps/skills/create-mcp-app/SKILL.md @@ -1,6 +1,6 @@ --- -name: create-mcp-app -description: This skill should be used when the user asks to "create an MCP App", "add a UI to an MCP tool", "build an interactive MCP View", "scaffold an MCP App", or needs guidance on MCP Apps SDK patterns, UI-resource registration, MCP App lifecycle, or host integration. Provides comprehensive guidance for building MCP Apps with interactive UIs. +name: Create MCP App +description: This skill should be used when the user asks to "create an MCP App", "add a UI to an MCP tool", "build an interactive MCP widget", "scaffold an MCP App", or needs guidance on MCP Apps SDK patterns, UI-resource registration, MCP App lifecycle, or host integration. Provides comprehensive guidance for building MCP Apps with interactive UIs. --- # Create MCP App @@ -13,10 +13,11 @@ Every MCP App requires two parts linked together: 1. **Tool** - Called by the LLM/host, returns data 2. **Resource** - Serves the bundled HTML UI that displays the data +3. **Link** - The tool's `_meta.ui.resourceUri` references the resource -The tool's `_meta.ui.resourceUri` references the resource's URI. - -Host calls tool โ†’ Host renders resource UI โ†’ Server returns result โ†’ UI receives result. +``` +Host calls tool โ†’ Server returns result โ†’ Host renders resource UI โ†’ UI receives result +``` ## Quick Start Decision Tree @@ -62,75 +63,65 @@ Learn and adapt from `/tmp/mcp-ext-apps/examples/basic-server-{framework}/`: | `basic-server-solid/` | `server.ts`, `src/mcp-app.tsx` | Each template includes: -- `server.ts` with `registerAppTool` and `registerAppResource` -- `main.ts` entry point with HTTP and stdio transport setup -- Client-side app (e.g., `src/mcp-app.ts`, `src/mcp-app.tsx`) with lifecycle handlers -- `src/global.css` with global styles and host style variable fallbacks -- `vite.config.ts` using `vite-plugin-singlefile` -- `package.json` with `npm run` scripts and required dependencies +- Complete `server.ts` with `registerAppTool` and `registerAppResource` +- Client-side app with all lifecycle handlers +- `vite.config.ts` with `vite-plugin-singlefile` +- `package.json` with all required dependencies - `.gitignore` excluding `node_modules/` and `dist/` ### API Reference (Source Files) -Read JSDoc documentation directly from `/tmp/mcp-ext-apps/src/`: +Read JSDoc documentation directly from source: | File | Contents | |------|----------| -| `src/app.ts` | `App` class, handlers (`ontoolinput`, `ontoolresult`, `onhostcontextchanged`, `onteardown`, etc.), lifecycle | -| `src/server/index.ts` | `registerAppTool`, `registerAppResource`, helper functions | -| `src/spec.types.ts` | All type definitions: `McpUiHostContext`, `McpUiStyleVariableKey` (CSS variable names), `McpUiResourceCsp` (CSP configuration), etc. | +| `src/app.ts` | `App` class, handlers (`ontoolinput`, `ontoolresult`, `onhostcontextchanged`, `onteardown`), lifecycle | +| `src/server/index.ts` | `registerAppTool`, `registerAppResource`, tool visibility options | +| `src/spec.types.ts` | All type definitions: `McpUiHostContext`, CSS variable keys, display modes | | `src/styles.ts` | `applyDocumentTheme`, `applyHostStyleVariables`, `applyHostFonts` | | `src/react/useApp.tsx` | `useApp` hook for React apps | +| `src/react/useHostStyles.ts` | `useHostStyles`, `useHostStyleVariables`, `useHostFonts` hooks | -### Advanced Patterns +### Advanced Examples -See `/tmp/mcp-ext-apps/docs/patterns.md` for detailed recipes: - -- **App-only tools** โ€” `visibility: ["app"]`, hiding tools from model -- **Polling** โ€” real-time dashboards, interval management -- **Chunked responses** โ€” large files, pagination, base64 encoding -- **Error handling** โ€” `isError`, informing model of failures -- **Binary resources** โ€” audio/video/etc via `resources/read`, blob field -- **Network requests** โ€” assets, fetch, CSP, `_meta.ui.csp`, CORS, `_meta.ui.domain` -- **Host context** โ€” theme, styling, fonts, safe area insets -- **Fullscreen mode** โ€” `requestDisplayMode`, display mode changes -- **Model context** โ€” `updateModelContext`, `sendMessage`, keeping model informed -- **View state** โ€” `viewUUID`, localStorage, state recovery -- **Visibility-based pause** โ€” IntersectionObserver, pausing animations/WebGL -- **Streaming input** โ€” `ontoolinputpartial`, progressive rendering - -### Reference Host Implementation - -`/tmp/mcp-ext-apps/examples/basic-host/` shows one way an MCP Apps-capable host could be implemented. Real-world hosts like Claude Desktop are more sophisticatedโ€”use basic-host for local testing and protocol understanding, not as a guarantee of host behavior. +| Example | Pattern Demonstrated | +|---------|---------------------| +| `examples/wiki-explorer-server/` | `callServerTool` for interactive data fetching | +| `examples/system-monitor-server/` | Polling pattern with interval management | +| `examples/video-resource-server/` | Binary/blob resources | +| `examples/sheet-music-server/` | `ontoolinput` - processing tool args before execution completes | +| `examples/threejs-server/` | `ontoolinputpartial` - streaming/progressive rendering | +| `examples/map-server/` | `updateModelContext` - keeping model informed of UI state | +| `examples/transcript-server/` | `updateModelContext` + `sendMessage` - background context updates + user-initiated messages | +| `examples/basic-host/` | Reference host implementation using `AppBridge` | ## Critical Implementation Notes ### Adding Dependencies -**Always** use `npm install` to add dependencies rather than manually writing version numbers: +Use `npm install` to add dependencies rather than manually writing version numbers: ```bash -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 concurrently cross-env @types/node @types/express @types/cors +npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod ``` -ext-apps 2.x requires the split base MCP SDK packages (`@modelcontextprotocol/client`, -`server`, `node`, `express`) at `^2.0.0`; `@modelcontextprotocol/core` comes in -transitively. Do not add the legacy `@modelcontextprotocol/sdk` v1 package or -substitute unpublished local packages. +This lets npm resolve the latest compatible versions. Never specify version numbers from memory. ### TypeScript Server Execution -Unless the user has specified otherwise, use `tsx` for running TypeScript server files. For example: +Use `tsx` as a devDependency for running TypeScript server files: ```bash npm install -D tsx - -npm pkg set scripts.dev="cross-env NODE_ENV=development concurrently 'cross-env INPUT=mcp-app.html vite build --watch' 'tsx --watch main.ts'" ``` -> [!NOTE] -> The SDK examples use `bun` but generated projects should default to `tsx` for broader compatibility. +```json +"scripts": { + "serve": "tsx server.ts" +} +``` + +Note: The SDK examples use `bun` but generated projects should use `tsx` for broader compatibility. ### Handler Registration Order @@ -144,19 +135,69 @@ app.ontoolinput = (params) => { /* handle input */ }; app.ontoolresult = (result) => { /* handle result */ }; app.onhostcontextchanged = (ctx) => { /* handle context */ }; app.onteardown = async () => { return {}; }; -// etc. // Then connect await app.connect(); ``` +### Tool Visibility + +Control who can access tools via `_meta.ui.visibility`: + +```typescript +// Default: visible to both model and app +_meta: { ui: { resourceUri, visibility: ["model", "app"] } } + +// UI-only (hidden from model) - for refresh buttons, form submissions +_meta: { ui: { resourceUri, visibility: ["app"] } } + +// Model-only (app cannot call) +_meta: { ui: { resourceUri, visibility: ["model"] } } +``` + +### Host Styling Integration + +**Vanilla JS** - Use helper functions: +```typescript +import { applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps"; + +app.onhostcontextchanged = (ctx) => { + if (ctx.theme) applyDocumentTheme(ctx.theme); + if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables); + if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts); +}; +``` + +**React** - Use hooks: +```typescript +import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react"; + +const { app } = useApp({ appInfo, capabilities, onAppCreated }); +useHostStyles(app); // Handles theme, styles, fonts automatically +``` + +### Safe Area Handling + +Always respect `safeAreaInsets`: + +```typescript +app.onhostcontextchanged = (ctx) => { + if (ctx.safeAreaInsets) { + const { top, right, bottom, left } = ctx.safeAreaInsets; + document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`; + } +}; +``` + ## Common Mistakes to Avoid -1. **No text fallback** - Always provide `content` array for non-UI hosts -2. **Missing CSP configuration** - MCP Apps HTML is served as an MCP resource with no same-origin server; ALL network requestsโ€”even to `localhost`โ€”require a CSP configuration -3. **CSP or CORS config in wrong _meta object** - `_meta.ui.csp` and `_meta.ui.domain` go in the `contents[]` objects returned by `registerAppResource()`'s read callback, not in `registerAppResource()`'s config object -4. **Handlers after app.connect()** - Register ALL handlers BEFORE calling `app.connect()` -5. **No streaming for large inputs** - Use `ontoolinputpartial` to show progress during input generation +1. **Handlers after connect()** - Register ALL handlers BEFORE calling `app.connect()` +2. **Missing single-file bundling** - Must use `vite-plugin-singlefile` +3. **Forgetting resource registration** - Both tool AND resource must be registered +4. **Missing resourceUri link** - Tool must have `_meta.ui.resourceUri` +5. **Ignoring safe area insets** - Always handle `ctx.safeAreaInsets` +6. **No text fallback** - Always provide `content` array for non-UI hosts +7. **Hardcoded styles** - Use host CSS variables for theme integration ## Testing diff --git a/plugins/mcp-apps/skills/migrate-oai-app/SKILL.md b/plugins/mcp-apps/skills/migrate-oai-app/SKILL.md deleted file mode 100644 index 6c63f85..0000000 --- a/plugins/mcp-apps/skills/migrate-oai-app/SKILL.md +++ /dev/null @@ -1,167 +0,0 @@ ---- -name: migrate-oai-app -description: This skill should be used when the user asks to "migrate from OpenAI Apps SDK", "convert OpenAI App to MCP", "port from window.openai", "migrate from skybridge", "convert openai/outputTemplate", or needs guidance on converting OpenAI Apps SDK applications to MCP Apps SDK. Provides step-by-step migration guidance with API mapping tables. ---- - -# Migrate OpenAI App to MCP - -Migrate existing OpenAI Apps SDK applications to the MCP Apps SDK (`@modelcontextprotocol/ext-apps`). The MCP Apps SDK provides a standardized, open protocol for interactive UIs in conversational clients. - -## Best Practices - -- Use your package manager to add dependencies (e.g., `npm install`, `pnpm add`, `yarn add`) instead of manually writing version numbers. This lets the package manager resolve the latest compatible versions. Never specify version numbers from memory. -- Preemptively add a final todo item with this exact wording: "Re-read the 'Before Finishing' checklist in this skill and address each checkbox individually, stating what you did for each one, before marking this todo complete." - -## Getting Reference Code - -Clone the SDK repository for complete migration documentation and working examples: - -```bash -git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-apps -``` - -### Migration Reference Guide - -Read the migration reference guide with "before/after" mapping tables: `/tmp/mcp-ext-apps/docs/migrate_from_openai_apps.md` - -### API Reference (Source Files) - -Read JSDoc documentation directly from `/tmp/mcp-ext-apps/src/*`: - -| File | Contents | -|------|----------| -| `src/app.ts` | `App` class, handlers, lifecycle | -| `src/server/index.ts` | `registerAppTool`, `registerAppResource` | -| `src/spec.types.ts` | Type definitions | -| `src/react/useApp.tsx` | `useApp` hook for React apps | -| `src/react/use*.ts*` | Other `use*` hooks for React apps | - -### Front-End Framework Examples - -See `/tmp/mcp-ext-apps/examples/basic-server-{framework}/` for basic SDK usage examples organized by front-end framework: - -| Template | Key Files | -|----------|-----------| -| `basic-server-vanillajs/` | `server.ts`, `src/mcp-app.ts`, `mcp-app.html` | -| `basic-server-react/` | `server.ts`, `src/mcp-app.tsx` (uses `useApp` hook) | -| `basic-server-vue/` | `server.ts`, `src/App.vue` | -| `basic-server-svelte/` | `server.ts`, `src/App.svelte` | -| `basic-server-preact/` | `server.ts`, `src/mcp-app.tsx` | -| `basic-server-solid/` | `server.ts`, `src/mcp-app.tsx` | - -## CSP Investigation - -MCP Apps HTML is served as an MCP resource, not as a web page, and runs in a sandboxed iframe with no same-origin server. **Every** origin must be declared in CSPโ€”including the origin serving your JS/CSS bundles (`localhost` in dev, your CDN in production). Missing origins fail silently. - -**Before writing any migration code**, build the app and investigate all origins it references: - -1. Build the app using the existing build command -2. Search the resulting HTML, CSS, and JS for **every** origin (not just "external" originsโ€”every network request will need CSP approval) -3. For each origin found, trace back to source: - - If it comes from a constant โ†’ universal (same in dev and prod) - - If it comes from an env var or conditional โ†’ note the mechanism and identify both dev and prod values -4. Check for third-party libraries that may make their own requests (analytics, error tracking, etc.) - -**Document your findings** as three lists, and note for each origin whether it's universal, dev-only, or prod-only: - -- **resourceDomains**: origins serving images, fonts, styles, scripts -- **connectDomains**: origins for API/fetch requests -- **frameDomains**: origins for nested iframes - -If no origins are found, the app may not need custom CSP domains. - -## CORS Configuration - -MCP clients make cross-origin requests. If using Express, `app.use(cors())` handles this. - -For raw HTTP servers, configure standard CORS and additionally: -- Allow headers: `mcp-session-id`, `mcp-protocol-version`, `last-event-id` -- Expose headers: `mcp-session-id` - -## Key Conceptual Changes - -### Server-Side - -Use `registerAppTool()` and `registerAppResource()` helpers instead of raw `server.registerTool()` / `server.registerResource()`. These helpers handle the MCP Apps metadata format automatically. - -See `/tmp/mcp-ext-apps/docs/migrate_from_openai_apps.md` for server-side mapping tables. - -### Client-Side - -The fundamental paradigm shift: OpenAI uses a synchronous global object (`window.openai.toolInput`, `window.openai.theme`) that's pre-populated before your code runs. MCP Apps uses an `App` instance with async event handlers. - -Key differences: -- Create an `App` instance and register handlers (`ontoolinput`, `ontoolresult`, `onhostcontextchanged`) **before** calling `connect()`. (Events may fire immediately after connection, so handlers must be registered first.) -- Access tool data via handlers: `app.ontoolinput` for `window.openai.toolInput`, `app.ontoolresult` for `window.openai.toolOutput`. -- Access host environment (theme, locale, etc.) via `app.getHostContext()`. - -For React apps, the `useApp` hook manages this lifecycle automaticallyโ€”see `basic-server-react/` for the pattern. - -See `/tmp/mcp-ext-apps/docs/migrate_from_openai_apps.md` for client-side mapping tables. - -### Features Not Yet Available in MCP Apps - -These OpenAI features don't have MCP equivalents yet: - -**Server-side:** -| OpenAI Feature | Status/Workaround | -|----------------|-------------------| -| `_meta["openai/toolInvocation/invoking"]` / `_meta["openai/toolInvocation/invoked"]` | Progress indicators not yet available | -| `_meta["openai/widgetDescription"]` | Use `app.updateModelContext()` for dynamic context | - -**Client-side:** -| OpenAI Feature | Status/Workaround | -|----------------|-------------------| -| `window.openai.widgetState` / `setWidgetState()` | Use `localStorage` or server-side state | -| `window.openai.uploadFile()` / `getFileDownloadUrl()` | File operations not yet available | -| `window.openai.requestModal()` / `requestClose()` | Modal management not yet available | -| `window.openai.view` | Not yet available | - -## Before Finishing - -Slow down and carefully follow each item in this checklist: - -- [ ] Search for and migrate any remaining server-side OpenAI patterns: - - | Pattern | Indicates | - |---------|-----------| - | `"openai/` | Old metadata keys โ†’ `_meta.ui.*` | - | `text/html+skybridge` | Old MIME type โ†’ `RESOURCE_MIME_TYPE` constant | - | `text/html;profile=mcp-app` | New MIME type, but prefer `RESOURCE_MIME_TYPE` constant | - | `_domains"` or `_domains:` | snake_case CSP โ†’ camelCase (`connect_domains` โ†’ `connectDomains`) | - -- [ ] Search for and migrate any remaining client-side OpenAI patterns: - - | Pattern | Indicates | - |---------|-----------| - | `window.openai.toolInput` | Old global โ†’ `params.arguments` in `ontoolinput` handler | - | `window.openai.toolOutput` | Old global โ†’ `params.structuredContent` in `ontoolresult` | - | `window.openai` | Old global API โ†’ `App` instance methods | - -- [ ] For each origin from your CSP investigation, show where it appears in the `registerAppResource()` CSP config. **Every** origin from the CSP investigation (universal, dev-only, prod-only) must be included in the CSP configโ€”MCP Apps HTML runs in a sandboxed iframe **with no same-origin server**. If an origin was not included in the CSP config, add it now. - -- [ ] For each conditional (dev-only, prod-only) origin from your CSP investigation, show the code where the same configuration setting (env var, config file, etc.) controls both the runtime URL and the CSP entry. If the CSP has a hardcoded origin that should be conditional, fix it nowโ€”the app must be production-ready. - -## Testing - -### Using basic-host - -Test the migrated app with the basic-host example: - -```bash -# Terminal 1: Build and run your server -npm run build && npm run serve - -# Terminal 2: Run basic-host (from cloned repo) -cd /tmp/mcp-ext-apps/examples/basic-host -npm install -SERVERS='["http://localhost:3001/mcp"]' npm run start -# Open http://localhost:8080 -``` - -### Verify Runtime Behavior - -Once the app loads in basic-host, confirm: -1. App loads without console errors -2. `ontoolinput` handler fires with tool arguments -3. `ontoolresult` handler fires with tool result diff --git a/scripts/bump-version.mjs b/scripts/bump-version.mjs deleted file mode 100644 index 65c878f..0000000 --- a/scripts/bump-version.mjs +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env node -/** - * Bump the version in the root package.json and sync all workspace packages - * to the same version. - * - * Usage: - * node scripts/bump-version.mjs patch - * node scripts/bump-version.mjs minor - * node scripts/bump-version.mjs major - * node scripts/bump-version.mjs 1.4.0 - * node scripts/bump-version.mjs prerelease --preid=beta - * - * Writes the new version to stdout (logs go to stderr). - */ - -import { execSync } from "node:child_process"; -import { readFileSync } from "node:fs"; - -const args = process.argv.slice(2); -if (!args[0]) { - console.error( - "Usage: node scripts/bump-version.mjs [--preid=]", - ); - process.exit(1); -} - -const exec = (cmd) => - execSync(cmd, { stdio: ["inherit", "pipe", "inherit"] }) - .toString() - .trim(); - -const pkgName = JSON.parse(readFileSync("package.json", "utf-8")).name; - -const newVersion = exec( - `npm version ${args.join(" ")} --no-git-tag-version`, -).replace(/^v/, ""); -exec(`npm pkg set version=${newVersion} --workspaces`); - -// Keep workspace dependency ranges compatible (needed on major bumps) -const [major, minor] = newVersion.split("."); -exec(`npm pkg set "dependencies.${pkgName}=^${major}.${minor}.0" --workspaces`); - -// Sync package-lock.json so `npm ci` doesn't reject the release PR -exec("npm install --package-lock-only --ignore-scripts"); - -console.error(`Bumped root + workspaces to ${newVersion}`); -console.log(newVersion); diff --git a/scripts/check-dependency-isolation.mjs b/scripts/check-dependency-isolation.mjs deleted file mode 100644 index 3809224..0000000 --- a/scripts/check-dependency-isolation.mjs +++ /dev/null @@ -1,209 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { - mkdtempSync, - mkdirSync, - readdirSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; - -const root = resolve(import.meta.dirname, ".."); -const packageJson = JSON.parse( - readFileSync(join(root, "package.json"), "utf8"), -); -const client = "@modelcontextprotocol/client"; -const server = "@modelcontextprotocol/server"; - -// App and AppBridge extend the client package's Protocol class, so client is -// a required peer for every consumer. The server helpers are only needed by -// server authors, so server stays optional: View-only consumers must not have -// it installed or bundled. -for (const role of [client, server]) { - if (!packageJson.peerDependencies?.[role]) { - throw new Error(`${role} must remain a peer dependency`); - } -} -if (packageJson.peerDependenciesMeta?.[client]?.optional) { - throw new Error(`${client} must be a required peer dependency`); -} -if (packageJson.peerDependenciesMeta?.[server]?.optional !== true) { - throw new Error(`${server} must be an optional peer dependency`); -} - -/** - * Exact version for a synthetic consumer dependency. Read from - * devDependencies so the consumers exercise the same SDK version the - * repository tests against; the check would silently drift if a range were - * allowed here. - */ -function exactDevDependency(name) { - const version = packageJson.devDependencies?.[name]; - if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version ?? "")) { - throw new Error( - `devDependencies["${name}"] must be an exact version, got ${JSON.stringify(version)}`, - ); - } - return version; -} - -/** Exact version of a package as installed in this repository's node_modules. */ -function installedVersion(name) { - return JSON.parse( - readFileSync(join(root, "node_modules", name, "package.json"), "utf8"), - ).version; -} - -const temporaryRoot = mkdtempSync(join(tmpdir(), "ext-apps-role-peers-")); -try { - // Minimal environment: nothing inherited from the caller's npm config - // (registry overrides, auth tokens, npm_config_* set by an outer `npm run`) - // can leak into the synthetic consumers. - const npmEnvironment = { - PATH: process.env.PATH ?? process.env.Path, - HOME: process.env.HOME ?? process.env.USERPROFILE, - npm_config_cache: join(temporaryRoot, "npm-cache"), - }; - const packDestination = join(temporaryRoot, "pack"); - mkdirSync(packDestination); - // `npm pack` runs the package's `prepare` script even with --ignore-scripts - // (pacote's directory fetcher), and its output can pollute stdout, so do not - // rely on `--json`: locate the tarball on disk instead. - execFileSync( - "npm", - ["pack", "--ignore-scripts", "--pack-destination", packDestination], - { cwd: root, stdio: "pipe", env: npmEnvironment }, - ); - const tarballs = readdirSync(packDestination).filter((name) => - name.endsWith(".tgz"), - ); - if (tarballs.length !== 1) { - throw new Error(`expected exactly one tarball, found ${tarballs}`); - } - const tarball = join(packDestination, tarballs[0]); - - const consumers = [ - { - // View / host author: ext-apps + client (+ react for the hooks entry). - name: "app-only", - dependencies: { - "@types/node": exactDevDependency("@types/node"), - "@types/react": installedVersion("@types/react"), - [client]: exactDevDependency(client), - "@modelcontextprotocol/ext-apps": `file:${tarball}`, - react: installedVersion("react"), - }, - absent: server, - // server must be neither installed (it is an optional peer) nor bundled. - mustNotInstall: true, - entry: - 'import { App } from "@modelcontextprotocol/ext-apps"; import { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; import { useApp } from "@modelcontextprotocol/ext-apps/react"; console.log(App, AppBridge, useApp);', - }, - { - // Server author: ext-apps + server. npm auto-installs client as a - // required peer (its types back the shared wire types), but the server - // entry must not pull it into a runtime bundle. - name: "server-only", - dependencies: { - "@types/node": exactDevDependency("@types/node"), - "@modelcontextprotocol/ext-apps": `file:${tarball}`, - [server]: exactDevDependency(server), - }, - absent: client, - mustNotInstall: false, - entry: - 'import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; console.log(registerAppTool);', - }, - ]; - - for (const consumer of consumers) { - const directory = join(temporaryRoot, consumer.name); - mkdirSync(directory); - writeFileSync( - join(directory, "package.json"), - JSON.stringify({ - private: true, - type: "module", - dependencies: consumer.dependencies, - }), - ); - execFileSync( - "npm", - [ - "install", - "--ignore-scripts", - "--package-lock=false", - "--no-audit", - "--no-fund", - ], - { cwd: directory, stdio: "inherit", env: npmEnvironment }, - ); - - if (consumer.mustNotInstall) { - const absentPath = join( - directory, - "node_modules", - ...consumer.absent.split("/"), - "package.json", - ); - try { - readFileSync(absentPath); - throw new Error( - `${consumer.name} unexpectedly installed ${consumer.absent}`, - ); - } catch (error) { - if (error?.code !== "ENOENT") throw error; - } - } - - writeFileSync(join(directory, "entry.ts"), consumer.entry); - writeFileSync( - join(directory, "tsconfig.json"), - JSON.stringify({ - compilerOptions: { - jsx: "react-jsx", - lib: ["ES2020", "DOM"], - module: "ESNext", - moduleResolution: "bundler", - noEmit: true, - skipLibCheck: false, - strict: true, - target: "ES2020", - }, - include: ["entry.ts"], - }), - ); - execFileSync( - process.execPath, - [join(root, "node_modules", "typescript", "bin", "tsc")], - { cwd: directory, stdio: "inherit" }, - ); - - const metafile = join(directory, "bundle-meta.json"); - execFileSync( - join(root, "node_modules", ".bin", "esbuild"), - [ - "entry.ts", - "--bundle", - "--platform=browser", - "--outfile=bundle.js", - `--metafile=${metafile}`, - ], - { cwd: directory, stdio: "inherit" }, - ); - const bundleInputs = Object.keys( - JSON.parse(readFileSync(metafile, "utf8")).inputs, - ).join("\n"); - if (bundleInputs.includes(`/node_modules/${consumer.absent}/`)) { - throw new Error( - `${consumer.name} unexpectedly bundled ${consumer.absent}`, - ); - } - } -} finally { - rmSync(temporaryRoot, { recursive: true, force: true }); -} - -console.log("Role peer dependency isolation checks passed."); diff --git a/scripts/check-versions.mjs b/scripts/check-versions.mjs index 0d0453f..c80ad52 100644 --- a/scripts/check-versions.mjs +++ b/scripts/check-versions.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * Checks that example package.json files reference a compatible version + * Checks that example package.json files reference the same version * of @modelcontextprotocol/ext-apps as the root package.json. * * This ensures examples stay in sync with the library version. @@ -13,38 +13,7 @@ const rootPkg = JSON.parse(readFileSync("package.json", "utf-8")); const rootVersion = rootPkg.version; const pkgName = rootPkg.name; -// Parse semver major.minor.patch -const [major, minor] = rootVersion.split(".").map(Number); - -/** - * Check if a dependency range is compatible with the root version. - * Allows: - * - "../.." (local dev) - * - "^X.Y.Z" where X.Y matches root major.minor (e.g., ^1.0.0 is compatible with 1.0.1) - * - Exact match like "1.0.1" - */ -function isCompatible(dep) { - if (dep === "../..") return true; - - // Handle caret ranges like ^1.0.0 - if (dep.startsWith("^")) { - const version = dep.slice(1); - const [depMajor, depMinor] = version.split(".").map(Number); - // For major version 0, minor must match; for major > 0, only major must match - if (major === 0) { - return depMajor === major && depMinor === minor; - } - return depMajor === major; - } - - // Handle exact version - if (/^\d+\.\d+\.\d+$/.test(dep)) { - const [depMajor] = dep.split(".").map(Number); - return depMajor === major; - } - - return false; -} +const expectedDep = `^${rootVersion}`; let hasError = false; @@ -59,22 +28,22 @@ for (const example of examples) { const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); const dep = pkg.dependencies?.[pkgName]; - if (dep && !isCompatible(dep)) { + // Allow "../.." (local dev) or the correct versioned dependency + if (dep && dep !== expectedDep && dep !== "../..") { console.error( - `โŒ ${pkgPath}: "${pkgName}": "${dep}" is not compatible with root version ${rootVersion}`, + `โŒ ${pkgPath}: expected "${pkgName}": "${expectedDep}" (or "../.."), got "${dep}"`, ); hasError = true; } } if (hasError) { - const expectedDep = `^${major}.${minor}.0`; console.error( `\nRun the following to fix:\n npm pkg set dependencies.${pkgName}=${expectedDep} --workspaces`, ); process.exit(1); } else { console.log( - `โœ… All examples reference compatible ${pkgName} versions (root version: ${rootVersion})`, + `โœ… All examples reference ${pkgName}@${expectedDep} (root version: ${rootVersion})`, ); } diff --git a/scripts/generate-schemas.ts b/scripts/generate-schemas.ts index 0ff9f04..513b2bf 100644 --- a/scripts/generate-schemas.ts +++ b/scripts/generate-schemas.ts @@ -8,26 +8,19 @@ * * ts-to-zod is a powerful tool but has limitations that require post-processing: * - * ### 1. Zod Import Path (`"zod"` โ†’ `"zod/v4"`) + * ### 1. Zod Import Path (keep standard `"zod"` for version agnosticism) * - * ts-to-zod generates `import { z } from "zod"`. We rewrite this to - * `import { z } from "zod/v4"` because the generated schemas compose with - * schemas imported from `@modelcontextprotocol/core`, which the SDK - * constructs via `zod/v4`. Mixing schemas from the v3 and v4 APIs at runtime - * fails with errors like `keyValidator._parse is not a function` (v3 internals - * calling into v4 objects, or vice versa). The `zod/v4` subpath is exported by - * both zod 3.25+ and zod 4.x, so the peerDependency range is preserved. + * ts-to-zod generates `import { z } from "zod"` which works with both + * Zod v3.25+ and v4. We keep this standard import to support both versions. * * ### 2. External Type References (`z.any()` โ†’ actual schemas) * * **Problem**: ts-to-zod cannot resolve types imported from external packages. * When it encounters types like `ContentBlock`, `CallToolResult`, `Implementation`, - * `RequestId`, and `Tool` (imported by spec.types.ts from - * `@modelcontextprotocol/client`), it generates `z.any()` as a placeholder. + * `RequestId`, and `Tool` from `@modelcontextprotocol/sdk`, it generates `z.any()` + * as a placeholder. * - * **Solution**: Import the corresponding Zod schemas from - * `@modelcontextprotocol/core` (the role-neutral package both client and - * server re-export) and remove the z.any() placeholders. + * **Solution**: Import the schemas from MCP SDK and remove the z.any() placeholders. * * ### 3. Index Signatures (`z.record().and()` โ†’ `z.object().passthrough()`) * @@ -77,10 +70,8 @@ const JSON_SCHEMA_OUTPUT_FILE = join(GENERATED_DIR, "schema.json"); const EXTERNAL_TYPE_SCHEMAS = [ "ContentBlockSchema", "CallToolResultSchema", - "EmbeddedResourceSchema", "ImplementationSchema", "RequestIdSchema", - "ResourceLinkSchema", "ToolSchema", ]; @@ -186,16 +177,14 @@ async function generateJsonSchema() { * Post-process generated schemas for project compatibility. */ function postProcess(content: string): string { - // 1. Rewrite to zod/v4 and add MCP SDK schema imports. - // zod/v4 aligns with the SDK's own zod import โ€” composing v3 and v4 - // schema instances throws at parse time. See header comment for details. + // 1. Add MCP SDK schema imports (keep standard zod import for v3/v4 compatibility) const mcpImports = EXTERNAL_TYPE_SCHEMAS.join(",\n "); content = content.replace( 'import { z } from "zod";', - `import { z } from "zod/v4"; + `import { z } from "zod"; import { ${mcpImports}, -} from "@modelcontextprotocol/core";`, +} from "@modelcontextprotocol/sdk/types.js";`, ); // 2. Remove z.any() placeholders for external types (now imported from MCP SDK) @@ -266,12 +255,7 @@ function replaceRecordAndWithPassthrough(content: string): string { * Post-process generated integration tests. */ function postProcessTests(content: string): string { - // Rewrite to zod/v4 so z.infer matches the v4 schemas in schema.ts. - content = content.replace( - 'import { z } from "zod";', - 'import { z } from "zod/v4";', - ); - + // Keep standard zod import for v3/v4 compatibility content = content.replace( "// Generated by ts-to-zod", `// Generated by ts-to-zod diff --git a/scripts/link-self.mjs b/scripts/link-self.mjs deleted file mode 100644 index c1f79cc..0000000 --- a/scripts/link-self.mjs +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Workaround: npm workspaces don't symlink the root package into node_modules - * when child workspaces depend on it โ€” it installs a stale registry copy instead. - * This script syncs the freshly-built dist/ and package.json into that copy - * so examples always type-check against the latest local types. - * See: https://github.com/npm/feedback/discussions/774 - */ -import { cpSync, existsSync, lstatSync } from "fs"; - -const target = "node_modules/@modelcontextprotocol/ext-apps"; -if (!existsSync(target)) process.exit(0); -// If target is a symlink (e.g. to the repo root), dist is already current. -if (lstatSync(target).isSymbolicLink()) process.exit(0); - -cpSync("dist", `${target}/dist`, { recursive: true }); -cpSync("package.json", `${target}/package.json`); diff --git a/scripts/setup-bun.mjs b/scripts/setup-bun.mjs new file mode 100644 index 0000000..3dc46a0 --- /dev/null +++ b/scripts/setup-bun.mjs @@ -0,0 +1,262 @@ +#!/usr/bin/env node +// Immediate log to verify script execution +console.log("[setup-bun] Script loaded"); + +/** + * Postinstall script to set up bun from platform-specific optional dependencies. + * Handles Windows ARM64 by downloading x64-baseline via emulation. + */ +import { + existsSync, + mkdirSync, + symlinkSync, + unlinkSync, + copyFileSync, + chmodSync, + writeFileSync, +} from "fs"; +import { join, dirname } from "path"; +import { spawnSync } from "child_process"; +import { fileURLToPath } from "url"; +import { get } from "https"; +import { createGunzip } from "zlib"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = join(__dirname, ".."); +const nodeModules = join(projectRoot, "node_modules"); +const binDir = join(nodeModules, ".bin"); + +const os = process.platform; +const arch = process.arch; +const isWindows = os === "win32"; +const bunExe = isWindows ? "bun.exe" : "bun"; + +// Detect libc type on Linux (glibc vs musl) +function detectLibc() { + if (os !== "linux") return null; + + // Check for musl-specific loader + const muslLoaders = [ + `/lib/ld-musl-${arch === "arm64" ? "aarch64" : "x86_64"}.so.1`, + "/lib/ld-musl-x86_64.so.1", + "/lib/ld-musl-aarch64.so.1", + ]; + + for (const loader of muslLoaders) { + if (existsSync(loader)) { + console.log(` Detected musl libc (found ${loader})`); + return "musl"; + } + } + + // Default to glibc on Linux + console.log(" Detected glibc (no musl loader found)"); + return "glibc"; +} + +// Platform to package mapping (matches @oven/bun-* package names) +// For Linux, separate glibc and musl packages +const platformPackages = { + darwin: { + arm64: ["bun-darwin-aarch64"], + x64: ["bun-darwin-x64", "bun-darwin-x64-baseline"], + }, + linux: { + arm64: { + glibc: ["bun-linux-aarch64"], + musl: ["bun-linux-aarch64-musl"], + }, + x64: { + glibc: ["bun-linux-x64", "bun-linux-x64-baseline"], + musl: ["bun-linux-x64-musl", "bun-linux-x64-musl-baseline"], + }, + }, + win32: { + x64: ["bun-windows-x64", "bun-windows-x64-baseline"], + arm64: ["bun-windows-x64-baseline"], // x64 runs via emulation on ARM64 + }, +}; + +function findBunBinary() { + let packages = platformPackages[os]?.[arch]; + + // For Linux, select packages based on libc type + if (os === "linux" && packages && typeof packages === "object") { + const libc = detectLibc(); + packages = packages[libc] || []; + } + + packages = packages || []; + console.log( + `Looking for bun packages: ${packages.join(", ") || "(none for this platform)"}`, + ); + + for (const pkg of packages) { + const binPath = join(nodeModules, "@oven", pkg, "bin", bunExe); + console.log(` Checking: ${binPath}`); + if (existsSync(binPath)) { + console.log(` Found bun at: ${binPath}`); + return binPath; + } else { + console.log(` Not found`); + } + } + + return null; +} + +async function downloadBunForWindowsArm64() { + // Windows ARM64 can run x64 binaries via emulation + const pkg = "bun-windows-x64-baseline"; + const version = "1.2.21"; + const url = `https://registry.npmjs.org/@oven/${pkg}/-/${pkg}-${version}.tgz`; + const destDir = join(nodeModules, "@oven", pkg); + + console.log(`Downloading ${pkg} for Windows ARM64 emulation...`); + + return new Promise((resolve, reject) => { + get(url, (response) => { + if (response.statusCode === 302 || response.statusCode === 301) { + get(response.headers.location, handleResponse).on("error", reject); + } else { + handleResponse(response); + } + + function handleResponse(res) { + if (res.statusCode !== 200) { + reject(new Error(`Failed to download: ${res.statusCode}`)); + return; + } + + const chunks = []; + const gunzip = createGunzip(); + + res.pipe(gunzip); + + gunzip.on("data", (chunk) => chunks.push(chunk)); + gunzip.on("end", () => { + try { + extractTar(Buffer.concat(chunks), destDir); + const binPath = join(destDir, "bin", bunExe); + if (existsSync(binPath)) { + resolve(binPath); + } else { + reject(new Error("Binary not found after extraction")); + } + } catch (err) { + reject(err); + } + }); + gunzip.on("error", reject); + } + }).on("error", reject); + }); +} + +function extractTar(buffer, destDir) { + // Simple tar extraction (512-byte blocks) + let offset = 0; + while (offset < buffer.length) { + const name = buffer + .toString("utf-8", offset, offset + 100) + .replace(/\0.*$/, "") + .replace("package/", ""); + const size = parseInt( + buffer.toString("utf-8", offset + 124, offset + 136).trim(), + 8, + ); + + offset += 512; + + if (!isNaN(size) && size > 0 && name) { + const filePath = join(destDir, name); + const fileDir = dirname(filePath); + if (!existsSync(fileDir)) { + mkdirSync(fileDir, { recursive: true }); + } + const content = buffer.subarray(offset, offset + size); + writeFileSync(filePath, content); + + // Make executable + if (name.endsWith(bunExe) || name === "bin/bun") { + try { + chmodSync(filePath, 0o755); + } catch {} + } + + offset += Math.ceil(size / 512) * 512; + } + } +} + +function setupBinLink(bunPath) { + if (!existsSync(binDir)) { + mkdirSync(binDir, { recursive: true }); + } + + const bunLink = join(binDir, bunExe); + const bunxLink = join(binDir, isWindows ? "bunx.exe" : "bunx"); + + // Remove existing links + for (const link of [bunLink, bunxLink]) { + try { + unlinkSync(link); + } catch {} + } + + if (isWindows) { + // On Windows, copy the binary (symlinks may not work without admin) + copyFileSync(bunPath, bunLink); + copyFileSync(bunPath, bunxLink); + } else { + // On Unix, use symlinks + symlinkSync(bunPath, bunLink); + symlinkSync(bunPath, bunxLink); + } + + console.log(`Bun linked to: ${bunLink}`); +} + +// Force immediate output +process.stdout.write("[setup-bun] Script starting...\n"); + +async function main() { + process.stdout.write(`[setup-bun] Setting up bun for ${os} ${arch}...\n`); + process.stdout.write(`[setup-bun] Project root: ${projectRoot}\n`); + process.stdout.write(`[setup-bun] Node modules: ${nodeModules}\n`); + + let bunPath = findBunBinary(); + + if (!bunPath && os === "win32" && arch === "arm64") { + try { + bunPath = await downloadBunForWindowsArm64(); + } catch (err) { + console.error("Failed to download bun for Windows ARM64:", err.message); + } + } + + if (!bunPath) { + console.log("No bun binary found in optional dependencies."); + console.log("Bun will need to be installed separately."); + console.log("See: https://bun.sh/docs/installation"); + process.exit(0); // Don't fail the install + } + + try { + setupBinLink(bunPath); + + // Verify installation + const result = spawnSync(bunPath, ["--version"], { encoding: "utf-8" }); + if (result.status === 0) { + console.log(`Bun ${result.stdout.trim()} installed successfully!`); + } + } catch (err) { + console.error("Failed to set up bun:", err.message); + process.exit(0); // Don't fail the install + } +} + +main().catch((err) => { + console.error(err); + process.exit(0); // Don't fail the install +}); diff --git a/scripts/sync-snippets.ts b/scripts/sync-snippets.ts deleted file mode 100644 index ee81d79..0000000 --- a/scripts/sync-snippets.ts +++ /dev/null @@ -1,543 +0,0 @@ -/** - * Code Snippet Sync Script - * - * This script syncs code snippets into JSDoc comments and markdown files - * containing labeled code fences. - * - * ## Supported Source Files - * - * - **Full-file inclusion**: Any file type (e.g., `.json`, `.yaml`, `.sh`, `.ts`) - * - **Region extraction**: Only `.ts` and `.tsx` files (using `//#region` markers) - * - * ## Code Fence Format - * - * Full-file inclusion (any file type): - * - * ``````typescript - * ```json source="./config.json" - * // entire file content is synced here - * ``` - * `````` - * - * Region extraction (.ts/.tsx only): - * - * ``````typescript - * ```ts source="./path.examples.ts#regionName" - * // region content is synced here - * ``` - * `````` - * - * Optionally, a display filename can be shown before the source reference: - * - * ``````typescript - * ```ts my-app.ts source="./path.examples.ts#regionName" - * // code is synced here - * ``` - * `````` - * - * ## Region Format (in .examples.ts files) - * - * ``````typescript - * //#region regionName - * // code here - * //#endregion regionName - * `````` - * - * Run: npm run sync:snippets - */ - -import { readFileSync, writeFileSync, readdirSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const PROJECT_ROOT = join(__dirname, ".."); -const SRC_DIR = join(PROJECT_ROOT, "src"); -const DOCS_DIR = join(PROJECT_ROOT, "docs"); - -/** Processing mode based on file type */ -type FileMode = "jsdoc" | "markdown"; - -/** - * Represents a labeled code fence found in a source file. - */ -interface LabeledCodeFence { - /** Optional display filename (e.g., "my-app.ts") */ - displayName?: string; - /** Relative path to the example file (e.g., "./app.examples.ts") */ - examplePath: string; - /** Region name (e.g., "App_basicUsage"), or undefined for whole file */ - regionName?: string; - /** Language from the code fence (e.g., "ts", "json", "yaml") */ - language: string; - /** Character index of the opening fence line start */ - openingFenceStart: number; - /** Character index after the opening fence line (after newline) */ - openingFenceEnd: number; - /** Character index of the closing fence line start */ - closingFenceStart: number; - /** The JSDoc line prefix extracted from context (e.g., " * ") */ - linePrefix: string; -} - -/** - * Cache for example file regions to avoid re-reading files. - * Key: `${absoluteExamplePath}#${regionName}` (empty regionName for whole file) - * Value: extracted code string - */ -type RegionCache = Map; - -/** - * Processing result for a source file. - */ -interface FileProcessingResult { - filePath: string; - modified: boolean; - snippetsProcessed: number; - errors: string[]; -} - -// JSDoc patterns - for code fences inside JSDoc comments with " * " prefix -// Matches: ``` [displayName] source="" or source="#" -// Example: " * ```ts my-app.ts source="./app.examples.ts#App_basicUsage"" -// Example: " * ```ts source="./app.examples.ts#App_basicUsage"" -// Example: " * ```ts source="./complete-example.ts"" (whole file) -const JSDOC_LABELED_FENCE_PATTERN = - /^(\s*\*\s*)```(\w+)(?:\s+(\S+))?\s+source="([^"#]+)(?:#([^"]+))?"/; -const JSDOC_CLOSING_FENCE_PATTERN = /^(\s*\*\s*)```\s*$/; - -// Markdown patterns - for plain code fences in markdown files (no prefix) -// Matches: ``` [displayName] source="" or source="#" -// Example: ```tsx source="./patterns.tsx#chunkedDataServer" -// Example: ```tsx source="./complete-example.tsx" (whole file) -const MARKDOWN_LABELED_FENCE_PATTERN = - /^```(\w+)(?:\s+(\S+))?\s+source="([^"#]+)(?:#([^"]+))?"/; -const MARKDOWN_CLOSING_FENCE_PATTERN = /^```\s*$/; - -/** - * Find all labeled code fences in a source file. - * @param content The file content - * @param filePath The file path (for error messages) - * @param mode The processing mode (jsdoc or markdown) - * @returns Array of labeled code fence references - */ -function findLabeledCodeFences( - content: string, - filePath: string, - mode: FileMode, -): LabeledCodeFence[] { - const results: LabeledCodeFence[] = []; - const lines = content.split("\n"); - let charIndex = 0; - - // Select patterns based on mode - const openPattern = - mode === "jsdoc" - ? JSDOC_LABELED_FENCE_PATTERN - : MARKDOWN_LABELED_FENCE_PATTERN; - const closePattern = - mode === "jsdoc" - ? JSDOC_CLOSING_FENCE_PATTERN - : MARKDOWN_CLOSING_FENCE_PATTERN; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const openMatch = line.match(openPattern); - - if (openMatch) { - let linePrefix: string; - let language: string; - let displayName: string | undefined; - let examplePath: string; - let regionName: string; - - if (mode === "jsdoc") { - // JSDoc: group 1=prefix, 2=lang, 3=displayName, 4=path, 5=region - [, linePrefix, language, displayName, examplePath, regionName] = - openMatch; - } else { - // Markdown: group 1=lang, 2=displayName, 3=path, 4=region (no prefix) - [, language, displayName, examplePath, regionName] = openMatch; - linePrefix = ""; - } - - const openingFenceStart = charIndex; - const openingFenceEnd = charIndex + line.length + 1; // +1 for newline - - // Find closing fence - let closingFenceStart = -1; - let searchIndex = openingFenceEnd; - - for (let j = i + 1; j < lines.length; j++) { - const closeLine = lines[j]; - if (closePattern.test(closeLine)) { - closingFenceStart = searchIndex; - break; - } - searchIndex += closeLine.length + 1; - } - - if (closingFenceStart === -1) { - throw new Error( - `${filePath}: No closing fence for ${examplePath}#${regionName}`, - ); - } - - results.push({ - displayName, - examplePath, - regionName, - language, - openingFenceStart, - openingFenceEnd, - closingFenceStart, - linePrefix, - }); - } - - charIndex += line.length + 1; - } - - return results; -} - -/** - * Dedent content by removing a base indentation prefix from each line. - * @param content The content to dedent - * @param baseIndent The indentation to remove - * @returns The dedented content - */ -function dedent(content: string, baseIndent: string): string { - if (!baseIndent) return content; - - const lines = content.split("\n"); - const dedentedLines = lines.map((line) => { - // Preserve empty lines as-is - if (line.trim() === "") return ""; - // Remove the base indentation if present - if (line.startsWith(baseIndent)) { - return line.slice(baseIndent.length); - } - // Line has less indentation than base - keep as-is - return line; - }); - - // Trim trailing empty lines - while ( - dedentedLines.length > 0 && - dedentedLines[dedentedLines.length - 1] === "" - ) { - dedentedLines.pop(); - } - - return dedentedLines.join("\n"); -} - -/** - * Extract a region from an example file. - * @param exampleContent The content of the example file - * @param regionName The region name to extract - * @param examplePath The example file path (for error messages) - * @returns The dedented region content - */ -function extractRegion( - exampleContent: string, - regionName: string, - examplePath: string, -): string { - // Region extraction only supported for .ts/.tsx files (uses //#region syntax) - if (!examplePath.endsWith(".ts") && !examplePath.endsWith(".tsx")) { - throw new Error( - `Region extraction (#${regionName}) is only supported for .ts/.tsx files. ` + - `Use full-file inclusion (without #regionName) for: ${examplePath}`, - ); - } - - const lineEnding = exampleContent.includes("\r\n") ? "\r\n" : "\n"; - const regionStart = `//#region ${regionName}${lineEnding}`; - const regionEnd = `//#endregion ${regionName}${lineEnding}`; - - const startIndex = exampleContent.indexOf(regionStart); - if (startIndex === -1) { - throw new Error(`Region "${regionName}" not found in ${examplePath}`); - } - - const endIndex = exampleContent.indexOf(regionEnd, startIndex); - if (endIndex === -1) { - throw new Error( - `Region end marker for "${regionName}" not found in ${examplePath}`, - ); - } - - // Get content after the region start line - const afterStart = exampleContent.indexOf("\n", startIndex); - if (afterStart === -1 || afterStart >= endIndex) { - return ""; // Empty region - } - - // Extract the raw content - const rawContent = exampleContent.slice(afterStart + 1, endIndex); - - // Determine base indentation from the //#region line - let lineStart = exampleContent.lastIndexOf("\n", startIndex); - lineStart = lineStart === -1 ? 0 : lineStart + 1; - const regionLine = exampleContent.slice(lineStart, startIndex); - - // The base indent is the whitespace before //#region - const baseIndent = regionLine; - - return dedent(rawContent, baseIndent); -} - -/** - * Get or load a region from the cache. - * @param sourceFilePath The source file requesting the region - * @param examplePath The relative path to the example file - * @param regionName The region name to extract, or undefined for whole file - * @param cache The region cache - * @returns The extracted code string - */ -function getOrLoadRegion( - sourceFilePath: string, - examplePath: string, - regionName: string | undefined, - cache: RegionCache, -): string { - // Resolve the example path relative to the source file - const sourceDir = dirname(sourceFilePath); - const absoluteExamplePath = resolve(sourceDir, examplePath); - - // File content is always cached with key ending in "#" (empty region) - const fileKey = `${absoluteExamplePath}#`; - let fileContent = cache.get(fileKey); - - if (fileContent === undefined) { - try { - fileContent = readFileSync(absoluteExamplePath, "utf-8").trim(); - } catch { - throw new Error(`Example file not found: ${absoluteExamplePath}`); - } - cache.set(fileKey, fileContent); - } - - // If no region name, return whole file - if (!regionName) { - return fileContent; - } - - // Extract region from cached file content, cache the result - const regionKey = `${absoluteExamplePath}#${regionName}`; - let regionContent = cache.get(regionKey); - - if (regionContent === undefined) { - regionContent = extractRegion(fileContent, regionName, examplePath); - cache.set(regionKey, regionContent); - } - - return regionContent; -} - -/** - * Format code lines for insertion into a JSDoc comment. - * @param code The code to format - * @param linePrefix The JSDoc line prefix (e.g., " * ") - * @returns The formatted code with JSDoc prefixes - */ -function formatCodeLines(code: string, linePrefix: string): string { - const lines = code.split("\n"); - return lines - .map((line) => - line === "" ? linePrefix.trimEnd() : `${linePrefix}${line}`, - ) - .join("\n"); -} - -/** - * Process a single source file to sync snippets. - * @param filePath The source file path - * @param cache The region cache - * @param mode The processing mode (jsdoc or markdown) - * @returns The processing result - */ -function processFile( - filePath: string, - cache: RegionCache, - mode: FileMode, -): FileProcessingResult { - const result: FileProcessingResult = { - filePath, - modified: false, - snippetsProcessed: 0, - errors: [], - }; - - let content: string; - try { - content = readFileSync(filePath, "utf-8"); - } catch (err) { - result.errors.push(`Failed to read file: ${err}`); - return result; - } - - let fences: LabeledCodeFence[]; - try { - fences = findLabeledCodeFences(content, filePath, mode); - } catch (err) { - result.errors.push(err instanceof Error ? err.message : String(err)); - return result; - } - - if (fences.length === 0) { - return result; - } - - const originalContent = content; - - // Process fences in reverse order to preserve positions - for (let i = fences.length - 1; i >= 0; i--) { - const fence = fences[i]; - - try { - const code = getOrLoadRegion( - filePath, - fence.examplePath, - fence.regionName, - cache, - ); - - const formattedCode = formatCodeLines(code, fence.linePrefix); - - // Replace content between opening fence end and closing fence start - content = - content.slice(0, fence.openingFenceEnd) + - formattedCode + - "\n" + - content.slice(fence.closingFenceStart); - - result.snippetsProcessed++; - } catch (err) { - result.errors.push( - `${filePath}: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - - if ( - result.snippetsProcessed > 0 && - result.errors.length === 0 && - content !== originalContent - ) { - writeFileSync(filePath, content); - result.modified = true; - } - - return result; -} - -/** - * Find all TypeScript source files in a directory, excluding examples, tests, and generated files. - * @param dir The directory to search - * @returns Array of absolute file paths - */ -function findSourceFiles(dir: string): string[] { - const files: string[] = []; - const entries = readdirSync(dir, { withFileTypes: true, recursive: true }); - - for (const entry of entries) { - if (!entry.isFile()) continue; - - const name = entry.name; - - // Only process .ts and .tsx files - if (!name.endsWith(".ts") && !name.endsWith(".tsx")) continue; - - // Exclude example files, test files - if (name.endsWith(".examples.ts") || name.endsWith(".examples.tsx")) - continue; - if (name.endsWith(".test.ts")) continue; - - // Get the relative path from the parent directory - const parentPath = entry.parentPath; - - // Exclude generated directory - if (parentPath.includes("/generated") || parentPath.includes("\\generated")) - continue; - - const fullPath = join(parentPath, name); - files.push(fullPath); - } - - return files; -} - -/** - * Find all markdown files in a directory. - * @param dir The directory to search - * @returns Array of absolute file paths - */ -function findMarkdownFiles(dir: string): string[] { - const files: string[] = []; - const entries = readdirSync(dir, { withFileTypes: true, recursive: true }); - - for (const entry of entries) { - if (!entry.isFile()) continue; - - // Only process .md files - if (!entry.name.endsWith(".md")) continue; - - const fullPath = join(entry.parentPath, entry.name); - files.push(fullPath); - } - - return files; -} - -async function main() { - console.log("๐Ÿ”ง Syncing code snippets from example files...\n"); - - const cache: RegionCache = new Map(); - const results: FileProcessingResult[] = []; - - // Process TypeScript source files (JSDoc mode) - const sourceFiles = findSourceFiles(SRC_DIR); - for (const filePath of sourceFiles) { - const result = processFile(filePath, cache, "jsdoc"); - results.push(result); - } - - // Process markdown documentation files - const markdownFiles = findMarkdownFiles(DOCS_DIR); - for (const filePath of markdownFiles) { - const result = processFile(filePath, cache, "markdown"); - results.push(result); - } - - // Report results - const modified = results.filter((r) => r.modified); - const errors = results.flatMap((r) => r.errors); - - if (modified.length > 0) { - console.log(`โœ… Modified ${modified.length} file(s):`); - for (const r of modified) { - console.log(` ${r.filePath} (${r.snippetsProcessed} snippet(s))`); - } - } else { - console.log("โœ… No files needed modification"); - } - - if (errors.length > 0) { - console.error("\nโŒ Errors:"); - for (const error of errors) { - console.error(` ${error}`); - } - process.exit(1); - } - - console.log("\n๐ŸŽ‰ Snippet sync complete!"); -} - -main().catch((error) => { - console.error("โŒ Snippet sync failed:", error); - process.exit(1); -}); diff --git a/scripts/typedoc-plugin-base-sdk-links.mjs b/scripts/typedoc-plugin-base-sdk-links.mjs deleted file mode 100644 index 6098918..0000000 --- a/scripts/typedoc-plugin-base-sdk-links.mjs +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Resolve module-local links inherited from the base MCP SDK declarations. - * - * TypeDoc's externalSymbolLinkMappings handles package-qualified symbols, but - * the base SDK writes this particular reference as `index.inputRequired`. - */ - -const BASE_SDK_DOCS = "https://ts.sdk.modelcontextprotocol.io/v2/"; - -/** @param {import("typedoc").Application} app */ -export function load(app) { - app.converter.addUnknownSymbolResolver((ref) => { - const name = ref.symbolReference?.path?.map((part) => part.path).join("."); - if (name === "index.inputRequired") return BASE_SDK_DOCS; - }); -} diff --git a/scripts/typedoc-plugin-fix-mermaid-entities.mjs b/scripts/typedoc-plugin-fix-mermaid-entities.mjs deleted file mode 100644 index c663aca..0000000 --- a/scripts/typedoc-plugin-fix-mermaid-entities.mjs +++ /dev/null @@ -1,102 +0,0 @@ -/** - * TypeDoc plugin that fixes mermaid diagram rendering issues. - * - * 1. Decodes HTML entities in mermaid code blocks before the mermaid plugin - * processes them. The @boneskull/typedoc-plugin-mermaid converts < to #lt; - * and > to #gt;, but those aren't valid mermaid entities. - * - * 2. Removes dark-theme mermaid divs to fix duplicate marker IDs. The mermaid - * plugin creates both dark and light variants with identical marker IDs - * (e.g., `#arrowhead`), causing the browser to resolve references to the - * wrong SVG. By keeping only light-theme divs, we avoid duplicate IDs. - * CSS filters handle dark mode styling. - */ - -import { Renderer } from "typedoc"; - -/** - * Decode HTML entities back to raw characters. - * @param {string} html - HTML-encoded string - * @returns {string} Decoded string - */ -function decodeHtmlEntities(html) { - return html - .replace(/&#(\d+);/g, (_, num) => String.fromCharCode(parseInt(num, 10))) - .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => - String.fromCharCode(parseInt(hex, 16)), - ) - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, "&"); // Must be last -} - -/** - * CSS to invert mermaid diagrams in dark mode. - * Since we only render light-theme diagrams, we use CSS filters for dark mode. - */ -const darkModeStyles = ` - -`; - -/** - * TypeDoc plugin entry point. - * @param {import('typedoc').Application} app - */ -export function load(app) { - // Use high priority (200) to run before the mermaid plugin (default is 0) - app.renderer.on( - Renderer.EVENT_END_PAGE, - (page) => { - if (!page.contents) return; - - // Find mermaid code blocks and decode HTML entities - page.contents = page.contents.replace( - /([\s\S]*?)<\/code>/g, - (match, code) => { - const decoded = decodeHtmlEntities(code); - return `${decoded}`; - }, - ); - }, - 200, - ); - - // Use low priority (-100) to run after the mermaid plugin injects its content - app.renderer.on( - Renderer.EVENT_END_PAGE, - (page) => { - if (!page.contents) return; - if (!page.contents.includes('class="mermaid-block"')) return; - - // Remove dark-theme mermaid divs to avoid duplicate marker IDs - page.contents = page.contents.replace( - /
[\s\S]*?<\/div>/g, - "", - ); - - // Also remove the CSS that hides light-theme divs by default - // The mermaid plugin adds visibility:hidden until JS sets display:block - // Since we only have light divs now, make them visible immediately - page.contents = page.contents.replace( - /
/g, - '
', - ); - - // Add dark mode CSS filter before - const headEndIndex = page.contents.indexOf(""); - if (headEndIndex !== -1) { - page.contents = - page.contents.slice(0, headEndIndex) + - darkModeStyles + - page.contents.slice(headEndIndex); - } - }, - -100, - ); -} diff --git a/scripts/typedoc-plugin-mcpstyle.mjs b/scripts/typedoc-plugin-mcpstyle.mjs deleted file mode 100644 index aea65bc..0000000 --- a/scripts/typedoc-plugin-mcpstyle.mjs +++ /dev/null @@ -1,67 +0,0 @@ -/** - * TypeDoc plugin that applies MCP-specific styling tweaks. - * - * - Moves custom.css to load last so overrides win the cascade - * - Replaces breadcrumbs with the document group name (e.g. "Security") - * - Marks the current sidebar nav link for CSS highlighting - * - Rewrites the README logo to a pair of theme-tagged s so - * the logo follows the TypeDoc theme switcher (data-theme), not just the - * OS prefers-color-scheme media query - */ - -import { Renderer } from "typedoc"; - -/** - * TypeDoc plugin entry point. - * @param {import('typedoc').Application} app - */ -export function load(app) { - app.renderer.on(Renderer.EVENT_END_PAGE, (page) => { - if (!page.contents) return; - - // Move custom.css to load after all theme stylesheets so overrides win the cascade - const customCssLink = page.contents.match( - //, - ); - if (customCssLink) { - page.contents = page.contents.replace(customCssLink[0], ""); - page.contents = page.contents.replace( - "", - customCssLink[0] + "\n", - ); - } - - // For document pages, replace the breadcrumb with the group name - // (e.g. "Security", "Getting Started"). The page title is in the H1. - if (page.model?.isDocument?.() && page.model.frontmatter?.group) { - const group = String(page.model.frontmatter.group); - page.contents = page.contents.replace( - /
    ]*>.*?<\/ul>/, - `
    • ${group}
    `, - ); - } - - // The README logo uses with prefers-color-scheme, which follows - // the OS setting and ignores the TypeDoc theme switcher (data-theme attr). - // Rewrite it as two tags โ€” CSS in mcp-theme.css picks the correct - // one based on both data-theme and prefers-color-scheme (for "OS" mode). - page.contents = page.contents.replace( - /\s*\s*\s*]*)>\s*<\/picture>/, - (_, darkSrc, lightSrc, imgAttrs) => - `` + - ``, - ); - - // Inject script to mark the current sidebar nav link with a "current" class. - // TypeDoc does not natively add this class for document pages. - // The sidebar is populated asynchronously from compressed navigation data, - // so we use a MutationObserver to detect when links appear. - // Pathname comparison strips trailing slashes and .html extensions to handle - // servers with clean-URL mode (e.g. `serve` drops .html). - const currentNavScript = ``; - page.contents = page.contents.replace( - "", - currentNavScript + "\n", - ); - }); -} diff --git a/scripts/typedoc-plugin-seo.mjs b/scripts/typedoc-plugin-seo.mjs deleted file mode 100644 index 11677e6..0000000 --- a/scripts/typedoc-plugin-seo.mjs +++ /dev/null @@ -1,315 +0,0 @@ -/** - * TypeDoc plugin that adds SEO metadata to generated pages. - * - * - Normalizes document page filenames to lowercase-hyphenated slugs - * - Injects JSON-LD (TechArticle / WebPage) structured data for search crawlers - * - Adds per-page meta description tags extracted from page content - * - Copies favicons to the output directory - */ - -import { Renderer } from "typedoc"; -import fs from "node:fs"; -import path from "node:path"; -import zlib from "node:zlib"; -import * as htmlparser2 from "htmlparser2"; - -const SITE_NAME = "MCP Apps"; - -/** - * Convert a TypeDoc-generated document filename to a lowercase hyphenated slug. - * e.g. "CSP_and_CORS.html" โ†’ "csp-and-cors.html" - * "Testing_MCP_Apps.html" โ†’ "testing-mcp-apps.html" - * @param {string} filename - * @returns {string} - */ -function toSlug(filename) { - return filename - .replace(/\.html$/, "") - .replace(/_/g, "-") - .toLowerCase() - .concat(".html"); -} - -/** - * Extract a plain-text description from the rendered HTML body. - * Parses the first content paragraph using htmlparser2 and truncates. - * @param {string} html - * @returns {string} - */ -function extractDescription(html) { - // Find the main content area - const contentMatch = html.match( - /
    ([\s\S]*?)<\/div>\s*<\/div>/, - ); - if (!contentMatch) return ""; - - const content = contentMatch[1]; - - // Find first

    that has real content (skip headings, code blocks, lists) - const paragraphs = content.match(/

    ([^<][\s\S]*?)<\/p>/g); - if (!paragraphs) return ""; - - for (const p of paragraphs) { - const text = htmlparser2 - .parseDocument(p) - .children.map((node) => htmlparser2.DomUtils.textContent(node)) - .join("") - .replace(/\s+/g, " ") - .trim(); - - // Skip very short or code-heavy paragraphs - if (text.length > 40) { - return text.length > 160 ? text.slice(0, 157) + "..." : text; - } - } - - return ""; -} - -/** - * Extract the page title from the tag. - * @param {string} html - * @returns {string} - */ -function extractTitle(html) { - const match = html.match(/<title>([^<]+)<\/title>/); - return match ? match[1] : SITE_NAME; -} - -/** - * Determine if a page is a document (guide) or API reference page. - * @param {string} url - * @returns {boolean} - */ -function isDocumentPage(url) { - return url.includes("documents/"); -} - -/** - * Build a JSON-LD structured data object for the page. - * @param {object} options - * @param {string} options.title - * @param {string} options.description - * @param {string} options.url - * @param {boolean} options.isDocument - * @returns {object} - */ -function buildJsonLd({ title, description, url, isDocument }) { - return { - "@context": "https://schema.org", - "@type": isDocument ? "TechArticle" : "WebPage", - name: title, - headline: title, - ...(description && { description }), - ...(url && { url }), - isPartOf: { - "@type": "WebSite", - name: SITE_NAME, - url: "https://apps.extensions.modelcontextprotocol.io/", - }, - }; -} - -/** - * TypeDoc plugin entry point. - * @param {import('typedoc').Application} app - */ -export function load(app) { - const hostedBaseUrl = app.options.getValue("hostedBaseUrl") || ""; - - // --- Per-page: inject JSON-LD, meta descriptions, and favicons --- - app.renderer.on(Renderer.EVENT_END_PAGE, (page) => { - if (!page.contents) return; - - const title = extractTitle(page.contents); - - // Prefer frontmatter description, fall back to auto-extraction - const frontmatterDesc = - page.model?.isDocument?.() && page.model.frontmatter?.description - ? String(page.model.frontmatter.description) - : ""; - const description = frontmatterDesc || extractDescription(page.contents); - const fullUrl = hostedBaseUrl - ? `${hostedBaseUrl.replace(/\/$/, "")}/${page.url}` - : ""; - - // Build JSON-LD - const jsonLd = buildJsonLd({ - title, - description, - url: fullUrl, - isDocument: isDocumentPage(page.url), - }); - - const jsonLdScript = `<script type="application/ld+json">${JSON.stringify(jsonLd)}</script>`; - - // Build meta description tag (replace the generic one if present) - const metaDescription = description - ? `<meta name="description" content="${description.replace(/"/g, """)}"/>` - : ""; - - // Replace existing generic meta description - if (metaDescription) { - page.contents = page.contents.replace( - /<meta name="description" content="Documentation for [^"]*"\/>/, - metaDescription, - ); - } - - // Build favicon tags with correct relative path - const depth = page.url.split("/").length - 1; - const base = depth > 0 ? "../".repeat(depth) : "./"; - const faviconTags = [ - `<link rel="icon" href="${base}favicons/favicon-32x32.png" type="image/png" sizes="32x32" media="(prefers-color-scheme: light)"/>`, - `<link rel="icon" href="${base}favicons/favicon-16x16.png" type="image/png" sizes="16x16" media="(prefers-color-scheme: light)"/>`, - `<link rel="shortcut icon" href="${base}favicons/favicon.ico" type="image/x-icon" media="(prefers-color-scheme: light)"/>`, - `<link rel="icon" href="${base}favicons/favicon-dark-32x32.png" type="image/png" sizes="32x32" media="(prefers-color-scheme: dark)"/>`, - `<link rel="icon" href="${base}favicons/favicon-dark-16x16.png" type="image/png" sizes="16x16" media="(prefers-color-scheme: dark)"/>`, - `<link rel="shortcut icon" href="${base}favicons/favicon-dark.ico" type="image/x-icon" media="(prefers-color-scheme: dark)"/>`, - `<link rel="apple-touch-icon" href="${base}favicons/apple-touch-icon.png" type="image/png" sizes="180x180"/>`, - ].join("\n"); - - // Inject favicons and JSON-LD before </head> - const headInjections = [faviconTags, jsonLdScript] - .filter(Boolean) - .join("\n"); - - page.contents = page.contents.replace( - "</head>", - headInjections + "\n</head>", - ); - }); - - // --- Post-render: copy favicons + rename document slugs --- - app.renderer.on(Renderer.EVENT_END, (event) => { - const outDir = event.outputDirectory; - - // Copy favicons to output directory - const srcFavicons = path.join(path.dirname(outDir), "favicons"); - const destFavicons = path.join(outDir, "favicons"); - if (fs.existsSync(srcFavicons)) { - if (!fs.existsSync(destFavicons)) fs.mkdirSync(destFavicons); - for (const file of fs.readdirSync(srcFavicons)) { - fs.copyFileSync( - path.join(srcFavicons, file), - path.join(destFavicons, file), - ); - } - } - - const docsDir = path.join(outDir, "documents"); - - if (!fs.existsSync(docsDir)) return; - - // Build rename map: old filename โ†’ new slug - const renameMap = new Map(); - for (const file of fs.readdirSync(docsDir)) { - if (!file.endsWith(".html")) continue; - const slug = toSlug(file); - if (slug !== file) { - renameMap.set(file, slug); - } - } - - if (renameMap.size === 0) return; - - // Rename the files - for (const [oldName, newName] of renameMap) { - fs.renameSync(path.join(docsDir, oldName), path.join(docsDir, newName)); - } - - // Build link replacement patterns (documents/OldName.html โ†’ documents/new-name.html) - const replacements = [...renameMap.entries()].map(([oldName, newName]) => ({ - old: `documents/${oldName}`, - new: `documents/${newName}`, - })); - - // Update all HTML files in the output directory - updateLinksRecursive(outDir, replacements); - - // Update compressed JS data files (navigation + search index) - updateCompressedJsData( - path.join(outDir, "assets", "navigation.js"), - "navigationData", - replacements, - ); - updateCompressedJsData( - path.join(outDir, "assets", "search.js"), - "searchData", - replacements, - ); - - // Update sitemap.xml - const sitemapPath = path.join(outDir, "sitemap.xml"); - if (fs.existsSync(sitemapPath)) { - let sitemap = fs.readFileSync(sitemapPath, "utf8"); - for (const r of replacements) { - sitemap = sitemap.replaceAll(r.old, r.new); - } - fs.writeFileSync(sitemapPath, sitemap); - } - }); -} - -/** - * Decompress a TypeDoc JS data file, replace document slugs, and recompress. - * TypeDoc stores navigation and search data as deflate-compressed base64. - * @param {string} filePath - * @param {string} varName - JS variable name (e.g. "navigationData", "searchData") - * @param {Array<{old: string, new: string}>} replacements - */ -function updateCompressedJsData(filePath, varName, replacements) { - if (!fs.existsSync(filePath)) return; - - let content = fs.readFileSync(filePath, "utf8"); - const pattern = new RegExp(`window\\.${varName} = "(.+)"`); - const match = content.match(pattern); - if (!match) return; - - // Decompress - const buf = Buffer.from(match[1], "base64"); - let json = zlib.inflateSync(buf).toString(); - - // Replace slugs - let changed = false; - for (const r of replacements) { - if (json.includes(r.old)) { - json = json.replaceAll(r.old, r.new); - changed = true; - } - } - - if (!changed) return; - - // Recompress and write - const compressed = zlib.deflateSync(json); - const encoded = compressed.toString("base64"); - content = content.replace(pattern, `window.${varName} = "${encoded}"`); - fs.writeFileSync(filePath, content); -} - -/** - * Recursively update links in all HTML files under a directory. - * @param {string} dir - * @param {Array<{old: string, new: string}>} replacements - */ -function updateLinksRecursive(dir, replacements) { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - updateLinksRecursive(fullPath, replacements); - } else if (entry.name.endsWith(".html") || entry.name.endsWith(".js")) { - let content = fs.readFileSync(fullPath, "utf8"); - let changed = false; - for (const r of replacements) { - if (content.includes(r.old)) { - content = content.replaceAll(r.old, r.new); - changed = true; - } - } - if (changed) { - fs.writeFileSync(fullPath, content); - } - } - } -} diff --git a/specification/2026-01-26/apps.mdx b/specification/2026-01-26/apps.mdx deleted file mode 100644 index 43fef20..0000000 --- a/specification/2026-01-26/apps.mdx +++ /dev/null @@ -1,1768 +0,0 @@ -<!-- For full edit history, see specification/draft/apps.mdx --> - -# SEP-1865: MCP Apps: Interactive User Interfaces for MCP - -**Track:** Extensions - -**Authors:** Ido Salomon, Liad Yosef, Olivier Chafik, Jerome Swannack, Jonathan Hefner, Anton Pidkuiko, Nick Cooper, Bryan Ashley, Alexi Christakis - -**Status:** Stable (2026-01-26) - -**Created:** 2025-11-21 - -## Abstract - -This SEP proposes an extension (per SEP-1724) to MCP that enables servers to deliver interactive user interfaces to hosts. MCP Apps introduces a standardized pattern for declaring UI resources via the `ui://` URI scheme, associating them with tools through metadata, and facilitating bidirectional communication between the UI and the host using MCP's JSON-RPC base protocol. This extension addresses the growing community need for rich, interactive experiences in MCP-enabled applications, maintaining security, auditability, and alignment with MCP's core architecture. The initial specification focuses on HTML resources (`text/html;profile=mcp-app`) with a clear path for future extensions. - -## Motivation - -MCP lacks a standardized way for servers to deliver rich, interactive user interfaces to hosts. This gap blocks many use cases that require visual presentation and interactivity that goes beyond plain text or structured data. As more hosts adopt this capability, there's a growing risk of fragmentation and interoperability challenges. - -[MCP-UI](https://mcpui.dev/) has demonstrated the viability and value of MCP apps built on UI resources and serves as a community playground for the UI spec and SDK. Fuelled by a dedicated community, it developed the bi-directional communication model and the HTML, external URL, and remote DOM content types. MCP-UI's adopters, including hosts and providers such as Postman, HuggingFace, Shopify, Goose, and ElevenLabs, have provided critical insights and contributions to the community. - -OpenAI's Apps SDK, launched in November 2025, further validated the demand for rich UI experiences within conversational AI interfaces. The Apps SDK enables developers to build rich, interactive applications inside ChatGPT using MCP as its backbone. - -The architecture of both the Apps SDK and MCP-UI have both significantly informed the design of this specification. - -However, without formal standardization: - -- Servers cannot reliably expect UI support via MCP -- Each host may implement slightly different behaviors -- Security and auditability patterns are inconsistent -- Developers must maintain separate implementations or adapters for different hosts (e.g., MCP-UI vs. Apps SDK) - -This SEP addresses the current limitations through an optional, backwards-compatible extension that unifies the approaches pioneered by MCP-UI and the Apps SDK into a single, open standard. - -## Specification - -### Extension Identifier - -This extension is identified as: `io.modelcontextprotocol/ui` - -### Overview - -MCP Apps extends the Model Context Protocol to enable servers to deliver interactive user interfaces to hosts. This extension introduces: - -- **UI Resources:** Predeclared resources using the `ui://` URI scheme -- **Tool-UI Linkage:** Tools reference UI resources via metadata -- **Bidirectional Communication:** UI iframes communicate with hosts using standard MCP JSON-RPC protocol -- **Security Model:** Mandatory iframe sandboxing with auditable communication - -This specification focuses on HTML content (`text/html;profile=mcp-app`) as the initial content type, with extensibility for future formats. - -As an extension, MCP Apps is optional and must be explicitly negotiated between clients and servers through the extension capabilities mechanism (see Capability Negotiation section). - -### UI Resource Format - -UI resources are declared using the standard MCP resource pattern with specific conventions: - -```typescript -interface UIResource { - /** - * Unique identifier for the UI resource - * - * MUST use the `ui://` URI scheme to distinguish UI resources from other - * MCP resource types. - * - * @example - * "ui://weather-dashboard" - */ - uri: string; - - /** - * Human-readable display name for the UI resource - * - * Used for listing and identifying the resource in host interfaces. - * - * @example - * "Weather Dashboard" - */ - name: string; - - /** - * Optional description of the UI resource's purpose and functionality - * - * Provides context about what the UI does and when to use it. - * - * @example - * "Interactive weather visualization with real-time updates" - */ - description?: string; - - /** - * MIME type of the UI content - * - * SHOULD be `text/html;profile=mcp-app` for HTML-based UIs in the initial MVP. - * Other content types are reserved for future extensions. - * - * @example - * "text/html;profile=mcp-app" - */ - mimeType: string; - - /** - * Resource metadata for security and rendering configuration - * - * Includes Content Security Policy configuration, dedicated domain settings, - * and visual preferences. - */ - _meta?: { - ui?: UIResourceMeta; - } -} - -interface McpUiResourceCsp { - /** - * Origins for network requests (fetch/XHR/WebSocket) - * - * - Empty or omitted = no external connections (secure default) - * - Maps to CSP `connect-src` directive - * - * @example - * ["https://api.weather.com", "wss://realtime.service.com"] - */ - connectDomains?: string[], - /** - * Origins for static resources (images, scripts, stylesheets, fonts, media) - * - * - Empty or omitted = no external resources (secure default) - * - Wildcard subdomains supported: `https://*.example.com` - * - Maps to CSP `img-src`, `script-src`, `style-src`, `font-src`, `media-src` directives - * - * @example - * ["https://cdn.jsdelivr.net", "https://*.cloudflare.com"] - */ - resourceDomains?: string[], - /** - * Origins for nested iframes - * - * - Empty or omitted = no nested iframes allowed (`frame-src 'none'`) - * - Maps to CSP `frame-src` directive - * - * @example - * ["https://www.youtube.com", "https://player.vimeo.com"] - */ - frameDomains?: string[], - /** - * Allowed base URIs for the document - * - * - Empty or omitted = only same origin allowed (`base-uri 'self'`) - * - Maps to CSP `base-uri` directive - * - * @example - * ["https://cdn.example.com"] - */ - baseUriDomains?: string[], -} - -interface UIResourceMeta { - /** - * Content Security Policy configuration - * - * Servers declare which external origins their UI needs to access. - * Hosts use this to enforce appropriate CSP headers. - */ - csp?: McpUiResourceCsp, - /** - * Sandbox permissions requested by the UI - * - * Servers declare which browser capabilities their UI needs. - * Hosts MAY honor these by setting appropriate iframe `allow` attributes. - * Apps SHOULD NOT assume permissions are granted; use JS feature detection as fallback. - */ - permissions?: { - /** - * Request camera access - * - * Maps to Permission Policy `camera` feature - */ - camera?: {}, - /** - * Request microphone access - * - * Maps to Permission Policy `microphone` feature - */ - microphone?: {}, - /** - * Request geolocation access - * - * Maps to Permission Policy `geolocation` feature - */ - geolocation?: {}, - /** - * Request clipboard write access - * - * Maps to Permission Policy `clipboard-write` feature - */ - clipboardWrite?: {}, - }, - /** - * Dedicated origin for view - * - * Optional domain for the view's sandbox origin. Useful when views need - * stable, dedicated origins for OAuth callbacks, CORS policies, or API key allowlists. - * - * **Host-dependent:** The format and validation rules for this field are - * determined by each host. Servers MUST consult host-specific documentation - * for the expected domain format. Common patterns include: - * - Hash-based subdomains (e.g., `{hash}.claudemcpcontent.com`) - * - URL-derived subdomains (e.g., `www-example-com.oaiusercontent.com`) - * - * If omitted, Host uses default sandbox origin (typically per-conversation). - * - * @example - * "a904794854a047f6.claudemcpcontent.com" - * @example - * "www-example-com.oaiusercontent.com" - */ - domain?: string, - /** - * Visual boundary preference - * - * Boolean controlling whether a visible border and background is provided by the host. Specifying an - * explicit value for this is recommended because hosts' defaults may vary. - * - * - `true`: Request visible border + background - * - `false`: Request no visible border + background - * - omitted: host decides border - */ - prefersBorder?: boolean, -} -``` - -The resource content is returned via `resources/read`: - -```typescript -// resources/read response for UI resource -{ - contents: [{ - uri: string; // Matching UI resource URI - mimeType: "text/html;profile=mcp-app"; // MUST be "text/html;profile=mcp-app" - text?: string; // HTML content as string - blob?: string; // OR base64-encoded HTML - _meta?: { - ui?: { - csp?: { - connectDomains?: string[]; // Origins for network requests (fetch/XHR/WebSocket). - resourceDomains?: string[]; // Origins for static resources (scripts, images, styles, fonts). - frameDomains?: string[]; // Origins for nested iframes (frame-src directive). - baseUriDomains?: string[]; // Allowed base URIs for the document (base-uri directive). - }; - permissions?: { - camera?: {}; // Request camera access - microphone?: {}; // Request microphone access - geolocation?: {}; // Request geolocation access - clipboardWrite?: {}; // Request clipboard write access - }; - domain?: string; - prefersBorder?: boolean; - }; - }; - }]; -} -``` - -#### Content Requirements: - -- URI MUST start with `ui://` scheme -- `mimeType` MUST be `text/html;profile=mcp-app` (other types reserved for future extensions) -- Content MUST be provided via either `text` (string) or `blob` (base64-encoded) -- Content MUST be valid HTML5 document - -#### Host Behavior: - -- **CSP Enforcement:** Host MUST construct CSP headers based on declared domains -- **Restrictive Default:** If `ui.csp` is omitted, Host MUST use: - - ``` - default-src 'none'; - script-src 'self' 'unsafe-inline'; - style-src 'self' 'unsafe-inline'; - img-src 'self' data:; - media-src 'self' data:; - connect-src 'none'; - ``` - -- **No Loosening:** Host MAY further restrict but MUST NOT allow undeclared domains -- **Audit Trail:** Host SHOULD log CSP configurations for security review - -Example: - -```json -// Resource declaration -{ - "uri": "ui://weather-server/dashboard-template", - "name": "weather_dashboard", - "description": "Interactive weather dashboard view", - "mimeType": "text/html;profile=mcp-app" -} - -// Resource content with metadata -{ - "contents": [{ - "uri": "ui://weather-server/dashboard-template", - "mimeType": "text/html;profile=mcp-app", - "text": "<!DOCTYPE html><html>...</html>", - "_meta": { - "ui" : { - "csp": { - "connectDomains": ["https://api.openweathermap.org"], - "resourceDomains": ["https://cdn.jsdelivr.net"] - }, - "prefersBorder": true - } - } - }] -} -``` - -### Resource Discovery - -Tools are associated with UI resources through the `_meta.ui` field: - -```typescript -interface McpUiToolMeta { - /** URI of UI resource for rendering tool results */ - resourceUri?: string; - /** - * Who can access this tool. Default: ["model", "app"] - * - "model": Tool visible to and callable by the agent - * - "app": Tool callable by the app from this server only - */ - visibility?: Array<"model" | "app">; -} - -interface Tool { - name: string; - description: string; - inputSchema: object; - _meta?: { - ui?: McpUiToolMeta; - /** @deprecated Use `ui.resourceUri` instead. Will be removed before GA. */ - "ui/resourceUri"?: string; - }; -} -``` - -> **Deprecation notice:** The flat `_meta["ui/resourceUri"]` format is deprecated. Use `_meta.ui.resourceUri` instead. The deprecated format will be removed before GA. - -Example (tool visible to both model and app): - -```json -{ - "name": "get_weather", - "description": "Get current weather for a location", - "inputSchema": { - "type": "object", - "properties": { - "location": { "type": "string" } - } - }, - "_meta": { - "ui": { - "resourceUri": "ui://weather-server/dashboard-template", - "visibility": ["model", "app"] - } - } -} -``` - -Example (app-only tool, hidden from model): - -```json -{ - "name": "refresh_dashboard", - "description": "Refresh dashboard data", - "inputSchema": { "type": "object" }, - "_meta": { - "ui": { - "resourceUri": "ui://weather-server/dashboard-template", - "visibility": ["app"] - } - } -} -``` - -#### Behavior: - -- If `ui.resourceUri` is present and host supports MCP Apps, host renders tool results using the specified UI resource -- If host does not support MCP Apps, tool behaves as standard tool (text-only fallback) -- Resource MUST exist on the server -- Host MUST use `resources/read` to fetch the referenced resource URI -- Host MAY prefetch and cache UI resource content for performance optimization -- Since UI resources are primarily discovered through tool metadata, Servers MAY omit UI-only resources from `resources/list` and `notifications/resources/list_changed` - -#### Visibility: - -- `visibility` defaults to `["model", "app"]` if omitted -- `"model"`: Tool is visible to and callable by the agent -- `"app"`: Tool is callable by the app from the same server connection only -- **tools/list behavior:** Host MUST NOT include tools in the agent's tool list when their visibility does not include `"model"` (e.g., `visibility: ["app"]`) -- **tools/call behavior:** Host MUST reject `tools/call` requests from apps for tools that don't include `"app"` in visibility -- Cross-server tool calls are always blocked for app-only tools - -#### Benefits: - -- **Performance:** Host can preload templates before tool execution -- **Security:** Host can review UI templates during connection setup -- **Caching:** Separate template (static) from data (dynamic) -- **Auditability:** All UI resources are enumerable and inspectable - -### Communication Protocol - -MCP Apps uses JSON-RPC 2.0 over `postMessage` for iframe-host communication. UI capabilities (e.g., tool call, prompt, etc.) will reuse MCPโ€™s existing protocol. In the future, we may choose to enrich the communication protocol with additional MCP capabilities. - -### Transport Layer - -Conceptually, UI iframes act as MCP clients, connecting to the host via a `postMessage` transport: - -```typescript -// UI iframe initializes MCP client -const transport = new MessageTransport(window.parent); -const client = new Client({ name: "ui-view", version: "1.0.0" }); -await client.connect(transport); -``` - -Note that you donโ€™t need an SDK to โ€œtalk MCPโ€ with the host: - -```typescript -let nextId = 1; -function sendRequest(method: string, params: any) { - const id = nextId++; - window.parent.postMessage({ jsonrpc: "2.0", id, method, params }, '*'); - return new Promise((resolve, reject) => { - window.addEventListener('message', function listener(event) { - const data: JSONRPCMessage = event.data; - if (event.data?.id === id) { - window.removeEventListener('message', listener); - if (event.data?.result) { - resolve(event.data?.result); - } else if (event.data?.error) { - reject(new Error(event.data.error)); - } - } else { - reject(new Error(`Unsupported message: ${JSON.stringify(data)}`)); - } - }); - }); -} -function sendNotification(method: string, params: any) { - window.parent.postMessage({ jsonrpc: "2.0", method, params }, '*'); -} -function onNotification(method: string, handler: (params: any) => void) { - window.addEventListener('message', function listener(event) { - if (event.data?.method === method) { - handler(event.data.params); - } - }); -} - - -const initializeResult = await sendRequest("initialize", { - capabilities: {}, - clientInfo: {name: "My UI", version: "1.0.0"}, - protocolVersion: "2026-01-26", -}); -``` - -Hosts act as MCP servers (that can proxy the actual MCP server), receiving and handling requests from UI iframes. - -### Sandbox proxy - -If the Host is a web page, it MUST wrap the View and communicate with it through an intermediate Sandbox proxy. - -1. The Host and the Sandbox MUST have different origins. -2. The Sandbox MUST have the following permissions: `allow-scripts`, `allow-same-origin`. -3. The Sandbox MUST send a `ui/notifications/sandbox-proxy-ready` notification to the host when it's ready to process an `ui/notifications/sandbox-resource-ready` notification. -4. Once the Sandbox is ready, the Host MUST send the raw HTML resource to load in a `ui/notifications/sandbox-resource-ready` notification. -5. The Sandbox MUST load the raw HTML of the View with CSP settings that: - - Enforce the domains declared in `ui.csp` metadata - - If `frameDomains` is provided, allow nested iframes from declared origins; otherwise use `frame-src 'none'` - - If `baseUriDomains` is provided, allow base URIs from declared origins; otherwise use `base-uri 'self'` - - Block dangerous features (`object-src 'none'`) - - Apply restrictive defaults if no CSP metadata is provided - - If `permissions` is declared, the Sandbox MAY set the inner iframe's `allow` attribute accordingly -6. The Sandbox MUST forward messages sent by the Host to the View, and vice versa, for any method that doesn't start with `ui/notifications/sandbox-`. This includes lifecycle messages, e.g., `ui/initialize` request & `ui/notifications/initialized` notification both sent by the View. The Host MUST NOT send any request or notification to the View before it receives an `initialized` notification. -7. The Sandbox SHOULD NOT create/send any requests to the Host or to the View (this would require synthesizing new request ids). -8. The Host MAY forward any message from the View (coming via the Sandbox) to the MCP Apps server, for any method that doesn't start with `ui/`. While the Host SHOULD ensure the View's MCP connection is spec-compliant, it MAY decide to block some messages or subject them to further user approval. - -### Standard MCP Messages - -UI iframes can use the following subset of standard MCP protocol messages: - -**Tools:** - -- `tools/call` - Execute a tool on the MCP server - -**Resources:** - -- `resources/read` - Read resource content - -**Notifications:** - -- `notifications/message` - Log messages to host - -**Lifecycle:** - -- `ui/initialize` โ†’ `ui/notifications/initialized` - MCP-like handshake (replaces custom iframe-ready pattern in MCP-UI) -- `ping` - Connection health check - -### App Capabilities in `ui/initialize` - -When the View sends an `ui/initialize` request to the Host, it MUST include its capabilities in the `appCapabilities` field: - -```typescript -interface McpUiAppCapabilities { - /** Experimental features (structure TBD). */ - experimental?: {}; - /** App exposes MCP-style tools that the host can call. */ - tools?: { - /** App supports tools/list_changed notifications. */ - listChanged?: boolean; - }; - /** - * Display modes the app supports. See Display Modes section for details. - * @example ["inline", "fullscreen"] - */ - availableDisplayModes?: Array<"inline" | "fullscreen" | "pip">; -} -``` - -### Host Context in `McpUiInitializeResult` - -When the View sends an `ui/initialize` request to the Host, the Host SHOULD include UI-specific context in the `McpUiInitializeResult`'s `hostContext` field: - -```typescript -interface HostContext { - /** Metadata of the tool call that instantiated the View */ - toolInfo?: { - /** JSON-RPC id of the tools/call request */ - id?: RequestId, - /** Contains name, inputSchema, etcโ€ฆ */ - tool: Tool, - }; - /** Current color theme preference */ - theme?: "light" | "dark"; - /** Style configuration for theming. See Theming section for standardized variable names. */ - styles?: { - /** CSS variables for theming */ - variables?: Record<McpUiStyleVariableKey, string | undefined>; - /** CSS blocks that Views can inject */ - css?: { - /** CSS for font loading (@font-face rules or @import statements) */ - fonts?: string; - }; - }; - /** How the View is currently displayed */ - displayMode?: "inline" | "fullscreen" | "pip"; - /** Display modes the host supports */ - availableDisplayModes?: string[]; - /** Container dimensions for the iframe. Specify either width or maxWidth, and either height or maxHeight. */ - containerDimensions?: ( - | { height: number } // If specified, container is fixed at this height - | { maxHeight?: number } // Otherwise, container height is determined by the View's height, up to this maximum height (if defined) - ) & ( - | { width: number } // If specified, container is fixed at this width - | { maxWidth?: number } // Otherwise, container width is determined by the View's width, up to this maximum width (if defined) - ); - /** User's language/region preference (BCP 47, e.g., "en-US") */ - locale?: string; - /** User's timezone (IANA, e.g., "America/New_York") */ - timeZone?: string; - /** Host application identifier */ - userAgent?: string; - /** Platform type for responsive design */ - platform?: "web" | "desktop" | "mobile"; - /** Device capabilities such as touch */ - deviceCapabilities?: { - touch?: boolean; - hover?: boolean; - } - /** Safe area boundaries in pixels */ - safeAreaInsets?: { - top: number; - right: number; - bottom: number; - left: number; - }; -} -``` - -Example: - -```json -// Host responds with McpUiInitializeResult -{ - "jsonrpc": "2.0", - "id": 1, - "result": { - "protocolVersion": "2026-01-26", - "hostCapabilities": { /* ... */ }, - "hostInfo": { "name": "claude-desktop", "version": "1.0.0" }, - "hostContext": { - "theme": "dark", - "styles": { - "variables": { - "--color-background-primary": "light-dark(#ffffff, #171717)", - "--color-text-primary": "light-dark(#171717, #fafafa)", - "--font-sans": "Anthropic Sans, sans-serif", - ... - }, - "css": { - "fonts": "@font-face { font-family: \"Custom Font Name\"; src: url(\"https://...\"); }" - } - }, - "displayMode": "inline", - "containerDimensions": { "width": 400, "maxHeight": 600 } - } - } -} -``` - -### Host Capabilities - -`HostCapabilities` are sent to the View as part of the response to `ui/initialize` (inside `McpUiInitializeResult`). -They describe the features and capabilities that the Host supports. - -```typescript -interface HostCapabilities { - /** Experimental features (structure TBD). */ - experimental?: {}; - /** Host supports opening external URLs. */ - openLinks?: {}; - /** Host can proxy tool calls to the MCP server. */ - serverTools?: { - /** Host supports tools/list_changed notifications. */ - listChanged?: boolean; - }; - /** Host can proxy resource reads to the MCP server. */ - serverResources?: { - /** Host supports resources/list_changed notifications. */ - listChanged?: boolean; - }; - /** Host accepts log messages. */ - logging?: {}; - /** Sandbox configuration applied by the host. */ - sandbox?: { - /** Permissions granted by the host (camera, microphone, geolocation, clipboard-write). */ - permissions?: { - camera?: {}; - microphone?: {}; - geolocation?: {}; - clipboardWrite?: {}; - }; - /** CSP domains approved by the host. */ - csp?: { - /** Approved origins for network requests (fetch/XHR/WebSocket). */ - connectDomains?: string[]; - /** Approved origins for static resources (scripts, images, styles, fonts). */ - resourceDomains?: string[]; - /** Approved origins for nested iframes (frame-src directive). */ - frameDomains?: string[]; - /** Approved base URIs for the document (base-uri directive). */ - baseUriDomains?: string[]; - }; - }; -} -``` - -### Container Dimensions - -The `HostContext` provides sizing information via `containerDimensions`: - -- **`containerDimensions`**: The dimensions of the container that holds the View. This controls the actual space the View occupies within the host. Each dimension (height and width) operates independently and can be either **fixed** or **flexible**. - -#### Dimension Modes - -| Mode | Dimensions Field | Meaning | -|------|-----------------|---------| -| Fixed | `height` or `width` | Host controls the size. View should fill the available space. | -| Flexible | `maxHeight` or `maxWidth` | View controls the size, up to the specified maximum. | -| Unbounded | Field omitted | View controls the size with no limit. | - -These modes can be combined independently. For example, a host might specify a fixed width but flexible height, allowing the View to grow vertically based on content. - -#### View Behavior - -Views should check the containerDimensions configuration and apply appropriate CSS: - -```typescript -// In the View's initialization -const containerDimensions = hostContext.containerDimensions; - -if (containerDimensions) { - // Handle height - if ("height" in containerDimensions) { - // Fixed height: fill the container - document.documentElement.style.height = "100vh"; - } else if ("maxHeight" in containerDimensions && containerDimensions.maxHeight) { - // Flexible with max: let content determine size, up to max - document.documentElement.style.maxHeight = `${containerDimensions.maxHeight}px`; - } - // If neither, height is unbounded - - // Handle width - if ("width" in containerDimensions) { - // Fixed width: fill the container - document.documentElement.style.width = "100vw"; - } else if ("maxWidth" in containerDimensions && containerDimensions.maxWidth) { - // Flexible with max: let content determine size, up to max - document.documentElement.style.maxWidth = `${containerDimensions.maxWidth}px`; - } - // If neither, width is unbounded -} -``` - -#### Host Behavior - -When using flexible dimensions (no fixed `height` or `width`), hosts MUST listen for `ui/notifications/size-changed` notifications from the View and update the iframe dimensions accordingly: - -```typescript -// Host listens for size changes from the View -bridge.onsizechange = ({ width, height }) => { - // Update iframe to match View's content size - if (width != null) { - iframe.style.width = `${width}px`; - } - if (height != null) { - iframe.style.height = `${height}px`; - } -}; -``` - -Views using the SDK automatically send size-changed notifications via ResizeObserver when `autoResize` is enabled (the default). The notifications are debounced and only sent when dimensions actually change. - -### Display Modes - -Views can be displayed in different modes depending on the host's capabilities and the view's declared support. - -```typescript -type McpUiDisplayMode = "inline" | "fullscreen" | "pip"; -``` - -- **inline**: Default mode, embedded within the host's content flow -- **fullscreen**: View takes over the full screen/window -- **pip**: Picture-in-picture, floating overlay - -#### Declaring Support - -**View (`appCapabilities.availableDisplayModes`):** - -Views declare which display modes they support in the `ui/initialize` request via `appCapabilities.availableDisplayModes`. This allows hosts to only offer display mode options that the view can handle. - -```typescript -// Example: View declares support for inline and fullscreen -{ - method: "ui/initialize", - params: { - appCapabilities: { - availableDisplayModes: ["inline", "fullscreen"] - }, - // ... - } -} -``` - -**Host (`HostContext.availableDisplayModes`):** - -Hosts declare which display modes they support in `HostContext.availableDisplayModes`. Views should check this before requesting a mode change. - -#### Requesting Changes - -Views request display mode changes via `ui/request-display-mode`. See the Requests section for message format. - -#### Notifying Changes - -Hosts notify views of display mode changes via `ui/notifications/host-context-changed` with the `displayMode` field. - -#### Requirements - -**View behavior:** -- View MUST declare all display modes it supports in `appCapabilities.availableDisplayModes` during initialization. -- View MUST check if the requested mode is in `availableDisplayModes` from host context before requesting a mode change. -- View MUST handle the response mode differing from the requested mode. - -**Host behavior:** -- Host MUST NOT switch the View to a display mode that does not appear in its `appCapabilities.availableDisplayModes`, if set. -- Host MUST return the resulting mode (whether updated or not) in the response to `ui/request-display-mode`. -- If the requested mode is not available, Host SHOULD return the current display mode in the response. -- Host MAY decline display mode requests from Views that did not declare said modes in their capabilities. - -### Theming - -Hosts can optionally pass CSS custom properties via `HostContext.styles.variables` for visual cohesion with the host environment. - -#### Current Standardized Variables - -```typescript -/** CSS variable keys available to Views for theming. */ -type McpUiStyleVariableKey = - // Background colors - | "--color-background-primary" - | "--color-background-secondary" - | "--color-background-tertiary" - | "--color-background-inverse" - | "--color-background-ghost" - | "--color-background-info" - | "--color-background-danger" - | "--color-background-success" - | "--color-background-warning" - | "--color-background-disabled" - // Text colors - | "--color-text-primary" - | "--color-text-secondary" - | "--color-text-tertiary" - | "--color-text-inverse" - | "--color-text-info" - | "--color-text-danger" - | "--color-text-success" - | "--color-text-warning" - | "--color-text-disabled" - | "--color-text-ghost" - // Border colors - | "--color-border-primary" - | "--color-border-secondary" - | "--color-border-tertiary" - | "--color-border-inverse" - | "--color-border-ghost" - | "--color-border-info" - | "--color-border-danger" - | "--color-border-success" - | "--color-border-warning" - | "--color-border-disabled" - // Ring colors - | "--color-ring-primary" - | "--color-ring-secondary" - | "--color-ring-inverse" - | "--color-ring-info" - | "--color-ring-danger" - | "--color-ring-success" - | "--color-ring-warning" - // Typography - Family - | "--font-sans" - | "--font-mono" - // Typography - Weight - | "--font-weight-normal" - | "--font-weight-medium" - | "--font-weight-semibold" - | "--font-weight-bold" - // Typography - Text Size - | "--font-text-xs-size" - | "--font-text-sm-size" - | "--font-text-md-size" - | "--font-text-lg-size" - // Typography - Heading Size - | "--font-heading-xs-size" - | "--font-heading-sm-size" - | "--font-heading-md-size" - | "--font-heading-lg-size" - | "--font-heading-xl-size" - | "--font-heading-2xl-size" - | "--font-heading-3xl-size" - // Typography - Text Line Height - | "--font-text-xs-line-height" - | "--font-text-sm-line-height" - | "--font-text-md-line-height" - | "--font-text-lg-line-height" - // Typography - Heading Line Height - | "--font-heading-xs-line-height" - | "--font-heading-sm-line-height" - | "--font-heading-md-line-height" - | "--font-heading-lg-line-height" - | "--font-heading-xl-line-height" - | "--font-heading-2xl-line-height" - | "--font-heading-3xl-line-height" - // Border radius - | "--border-radius-xs" - | "--border-radius-sm" - | "--border-radius-md" - | "--border-radius-lg" - | "--border-radius-xl" - | "--border-radius-full" - // Border width - | "--border-width-regular" - // Shadows - | "--shadow-hairline" - | "--shadow-sm" - | "--shadow-md" - | "--shadow-lg"; -``` - -#### Host Behavior - -- Hosts can provide any subset of standardized variables, or not pass `styles` at all - - However, unexpected clashes may occur if hosts pass some color variables but not others for example, since Views are instructed to fallback on their own default values for unspecified style variables -- Hosts should use the CSS `light-dark()` function for theme-aware values (i.e. light mode and dark mode colors) - -#### View Behavior - -- Views should set default fallback values for the set of these variables that they use, to account for hosts who don't pass some or all style variables. This ensures graceful degradation when hosts omit `styles` or specific variables: -``` -:root { - --color-text-primary: light-dark(#171717, #000000); - --border-radius-small: 8px; - ... -} -``` -- Views can use the `applyHostStyleVariables` utility (or `useHostStyleVariables` if they prefer a React hook) to easily populate the host-provided CSS variables into their style sheet -- Views can use the `applyDocumentTheme` utility (or `useDocumentTheme` if they prefer a React hook) to easily respond to Host Context `theme` changes in a way that is compatible with the host's light/dark color variables - -Example usage of standardized CSS variables: - -```css -.container { - background: var(--color-background-primary); - color: var(--color-text-primary); - font-family: var(--font-sans); -} -``` - -#### Custom Fonts - -Hosts can provide custom fonts via `styles.css.fonts`, which can contain `@font-face` rules for self-hosted fonts, `@import` statements for font services like Google Fonts, or both: - -```typescript -hostContext.styles.variables["--font-sans"] = '"Font Name", sans-serif'; - -// Self-hosted fonts -hostContext.styles.css.fonts = ` - @font-face { - font-family: "Font Name"; - src: url("https://url-where-font-is-hosted.com/.../Regular.otf") format("opentype"); - font-weight: 400; - font-style: normal; - font-display: swap; - } - @font-face { - font-family: "Font Name"; - src: url("https://url-where-font-is-hosted.com/.../Medium.otf") format("opentype"); - font-weight: 500; - font-style: medium; - font-display: swap; - } -`; - -// Google Fonts -hostContext.styles.css.fonts = ` - @import url('https://fonts.googleapis.com/css2?family=Font+Name&display=swap'); -`; -``` - -Views can use the `applyHostFonts` utility to inject the font CSS into the document: - -```typescript -if (hostContext.styles?.css?.fonts) { - applyHostFonts(hostContext.styles.css.fonts); -} -``` - -### MCP Apps Specific Messages - -MCP Apps introduces additional JSON-RPC methods for UI-specific functionality: - -#### Requests (View โ†’ Host) - -`ui/open-link` - Request host to open external URL - -```typescript -// Request -{ - jsonrpc: "2.0", - id: 1, - method: "ui/open-link", - params: { - url: string // URL to open in host's browser - } -} - -// Success Response -{ - jsonrpc: "2.0", - id: 1, - result: {} // Empty result on success -} - -// Error Response (if denied or failed) -{ - jsonrpc: "2.0", - id: 1, - error: { - code: -32000, // Implementation-defined error - message: "Link opening denied by user" | "Invalid URL" | "Policy violation" - } -} -``` - -Host SHOULD open the URL in the user's default browser or a new tab. - -`ui/message` - Send message content to the host's chat interface - -```typescript -// Request -{ - jsonrpc: "2.0", - id: 2, - method: "ui/message", - params: { - role: "user", - content: { - type: "text", - text: string - } - } -} - -// Success Response -{ - jsonrpc: "2.0", - id: 2, - result: {} // Empty result on success -} - -// Error Response (if denied or failed) -{ - jsonrpc: "2.0", - id: 2, - error: { - code: -32000, // Implementation-defined error - message: "Message sending denied" | "Invalid message format" - } -} -``` -Host behavior: -* Host SHOULD add the message to the conversation context, preserving the specified role. -* Host MAY request user consent. - -`ui/request-display-mode` - Request host to change display mode - -```typescript -// Request -{ - jsonrpc: "2.0", - id: 3, - method: "ui/request-display-mode", - params: { - mode: "inline" | "fullscreen" | "pip" // Requested display mode - } -} - -// Success Response -{ - jsonrpc: "2.0", - id: 3, - result: { - mode: "inline" | "fullscreen" | "pip" // Actual display mode set - } -} -``` - -See the Display Modes section for detailed behavior requirements. - -`ui/update-model-context` - Update the model context - -```typescript -// Request -{ - jsonrpc: "2.0", - id: 3, - method: "ui/update-model-context", - params: { - content?: ContentBlock[], - structuredContent?: Record<string, unknown> - } -} - -// Success Response -{ - jsonrpc: "2.0", - id: 3, - result: {} // Empty result on success -} - -// Error Response (if denied or failed) -{ - jsonrpc: "2.0", - id: 3, - error: { - code: -32000, // Implementation-defined error - message: "Context update denied" | "Invalid content format" - } -} -``` - -The View MAY send this request to update the Host's model context. This context will be used in future turns. Each request overwrites the previous context sent by the View. -This event serves a different use case from `notifications/message` (logging) and `ui/message` (which also trigger follow-ups). - -Host behavior: -- SHOULD provide the context to the model in future turns -- MAY overwrite the previous model context with the new update -- MAY defer sending the context to the model until the next user message (including `ui/message`) -- MAY dedupe identical `ui/update-model-context` calls -- If multiple updates are received before the next user message, Host SHOULD only send the last update to the model -- MAY display context updates to the user - -#### Notifications (Host โ†’ View) - -`ui/notifications/tool-input` - Host MUST send this notification with the complete tool arguments after the View's initialize request completes. - -```typescript -{ - jsonrpc: "2.0", - method: "ui/notifications/tool-input", - params: { - arguments: Record<string, unknown> // Tool input arguments - } -} -``` - -Host sends this notification after the View's initialize request completes, when tool arguments become available. This notification is sent at most once and is required before sending `ui/notifications/tool-result`. - -`ui/notifications/tool-input-partial` - Host MAY send this notification zero or more times while the agent is streaming tool arguments, before `ui/notifications/tool-input` is sent. - -```typescript -{ - jsonrpc: "2.0", - method: "ui/notifications/tool-input-partial", - params: { - arguments: Record<string, unknown> // Tool input arguments - } -} -``` - -The arguments object represents best-effort recovery of incomplete JSON, with unclosed structures automatically closed to produce valid JSON. Host behavior (optional): - -- MAY parse the agent's partial JSON output by closing unclosed brackets/braces -- MAY send recovered arguments as they become available during streaming -- MUST stop sending once `ui/notifications/tool-input` is sent with complete arguments - -View behavior (optional): - -- MAY ignore these notifications entirely -- MAY render progressive loading/streaming states based on available fields -- MUST NOT rely on partial arguments for critical operations -- SHOULD gracefully handle missing or changing fields between notifications - -`ui/notifications/tool-result` - Tool execution result - -```typescript -{ - jsonrpc: "2.0", - method: "ui/notifications/tool-result", - params: CallToolResult // Standard MCP type -} -``` - -Host MUST send this notification when tool execution completes (if the View is displayed during tool execution). - -`ui/notifications/tool-cancelled` - Tool execution was cancelled - -```typescript -{ - jsonrpc: "2.0", - method: "ui/notifications/tool-cancelled", - params: { - reason: string - } -} -``` - -Host MUST send this notification if the tool execution was cancelled, for any reason (which can optionally be specified), including user action, sampling error, classifier intervention, etc. - -`ui/resource-teardown` - Host notifies View before teardown - -```typescript -{ - jsonrpc: "2.0", - id: 1, - method: "ui/resource-teardown", - params: { - reason: string - } -} - -// Success Response -{ - jsonrpc: "2.0", - id: 1, - result: {} // Empty result on success -} - -// Error Response (if failed) -{ - jsonrpc: "2.0", - id: 1, - error: { - code: -32000, // Implementation-defined error - message: "Teardown error" - } -} -``` - -Host MUST send this notification before tearing down the UI resource, for any reason, including user action, resource re-allocation, etc. The Host MAY specify the reason. -Host SHOULD wait for a response before tearing down the resource (to prevent data loss). - -`ui/notifications/size-changed` - View's size changed - -```typescript -{ - jsonrpc: "2.0", - method: "ui/notifications/size-changed", - params: { - width: number, // Viewport width in pixels - height: number // Viewport height in pixels - } -} -``` - -The View SHOULD send this notification when rendered content body size changes (e.g. using ResizeObserver API to report up to date size). - -`ui/notifications/host-context-changed` - Host context has changed - -```typescript -{ - jsonrpc: "2.0", - method: "ui/notifications/host-context-changed", - params: Partial<HostContext> // See HostContext type above -} -``` - -Host MAY send this notification when any context field changes (e.g., theme toggle, display mode change, device orientation change, window/panel resize). This notification contains partial updates - the View SHOULD merge received fields with its current context state. - -#### Reserved Messages (Sandbox Proxy) - -These messages are reserved for web-based hosts that implement the recommended double-iframe sandbox architecture: - -`ui/notifications/sandbox-proxy-ready` (Sandbox Proxy โ†’ Host) - Sandbox proxy is ready - -```typescript -{ - jsonrpc: "2.0", - method: "ui/notifications/sandbox-proxy-ready", - params: {} -} -``` - -`ui/notifications/sandbox-resource-ready` (Host โ†’ Sandbox Proxy) - HTML resource ready to load - -```typescript -{ - jsonrpc: "2.0", - method: "ui/notifications/sandbox-resource-ready", - params: { - html: string, // HTML content to load - sandbox?: string, // Optional override for inner iframe `sandbox` attribute - csp?: { // CSP configuration from resource metadata - connectDomains?: string[], - resourceDomains?: string[], - frameDomains?: string[], - baseUriDomains?: string[], - }, - permissions?: { // Sandbox permissions from resource metadata - camera?: {}, - microphone?: {}, - geolocation?: {}, - clipboardWrite?: {}, - } - } -} -``` - -These messages facilitate the communication between the outer sandbox proxy iframe and the host, enabling secure loading of untrusted HTML content. The `permissions` field maps to the inner iframe's `allow` attribute for Permission Policy features. - -### Lifecycle - -The typical lifecycle for rendering a UI resource: - -#### 1. Connection & Discovery - -```mermaid -sequenceDiagram - participant H as Host - participant S as MCP Server - - autonumber - S -->> H: resources/list (includes ui:// resources) - S -->> H: tools/list (includes tools with _meta.ui metadata) -``` - -#### 2. UI Initialization (Desktop/Native Hosts) - -```mermaid -sequenceDiagram - participant H as Host - participant UI as View (iframe) - participant P as Sandbox Proxy - participant S as MCP Server - - autonumber - par UI Tool call - H ->> S: tools/call to Tool with _meta.ui metadata - and UI initialization - alt Desktop/Native hosts - H ->> H: Render View in an iframe (HTML from the ui:// resource) - else Web hosts - H ->> H: Render Sandbox Proxy in an iframe (different origin) - P ->> H: ui/notifications/sandbox-proxy-ready - H -->> P: ui/notifications/sandbox-resource-ready (HTML content) - P -> P: Render inner iframe with HTML - - end - UI ->> H: ui/initialize - H -->> UI: McpUiInitializeResult (e.g., host-context, capabilities, etc.) - UI ->> H: ui/notifications/initialized - opt Stream Tool input to UI - H -->> UI: ui/notifications/tool-input-partial (0..n) - end - H -->> UI: ui/notifications/tool-input (complete) - end - alt Tool complete - H -->> UI: ui/notifications/tool-result - else Tool cancelled - H -->> UI: ui/notifications/tool-cancelled - end -``` - -Note: when the View is rendered inside a sandbox, the sandbox transparently passes messages between the View and the Host, except for messages named `ui/notifications/sandbox-*`. - -#### 3. Interactive Phase - -```mermaid -sequenceDiagram - actor U as User / Agent - participant H as Host - participant UI as View (iframe) - participant S as MCP Server - loop Interactive phase - U ->> UI: interaction (e.g., click) - alt Tool call - UI ->> H: tools/call - H ->> S: tools/call - opt Stream Tool input to UI - H -->> UI: ui/notifications/tool-input-partial (0..n) - end - H -->> UI: ui/notifications/tool-input (complete) - H-->>UI: ui/notifications/tool-result - else Message - UI ->> H: ui/message - H -->> UI: ui/message response - H -->> H: Process message and follow up - else Context update - UI ->> H: ui/update-model-context - H ->> H: Store model context (overwrite existing) - H -->> UI: ui/update-model-context response - else Log - UI ->> H: notifications/message - H ->> H: Record log for debugging/telemetry - else Resource read - UI ->> H: resources/read - H ->> S: resources/read - S --> H: resources/read response - H --> UI: resources/read response - end - opt View notifications - UI ->> H: notifications (e.g., ui/notifications/size-changed) - end - opt Host notifications - H ->> UI: notifications (e.g., ui/notifications/host-context-changed) - end - end -``` - -#### 4. Cleanup - -```mermaid -sequenceDiagram - participant H as Host - participant UI as View (iframe) - H ->> UI: ui/resource-teardown - UI --> UI: Graceful termination - UI -->> H: ui/resource-teardown response - H -x H: Tear down iframe and listeners -``` - -Note: Cleanup may be triggered at any point in the lifecycle following View initialization. - -#### Key Differences from Pre-SEP MCP-UI: - -- Handshakes using MCP-like `ui/initialize` / `ui/notifications/initialized` messages instead of `iframe-ready` -- Capabilities negotiated via `McpUiInitializeResult` -- Tool data passed via notifications - -### Data Passing - -Tool execution results are passed to the View through two mechanisms: - -#### 1. Tool Input (via `ui/notifications/tool-input` notification) - -The original tool call arguments: - -```typescript -// Tool was called with (JSON-RPC request from Host to Server): -{ - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { - name: "get_weather", - arguments: { - location: "San Francisco" - } - } -} - -// View receives (JSON-RPC notification from Host to View): -{ - jsonrpc: "2.0", - method: "ui/notifications/tool-input", - params: { - arguments: { - location: "San Francisco" - } - } -} -``` - -#### 2. Tool Result (via `ui/notifications/tool-result` notification) - -The tool's execution result: - -```typescript -// Server returns from tool execution (JSON-RPC response): -{ - jsonrpc: "2.0", - id: 1, // Matches the tools/call request id - result: { - content: [ - { type: "text", text: "Current weather: Sunny, 72ยฐF" } - ], - structuredContent: { - temperature: 72, - conditions: "sunny", - humidity: 45 - }, - _meta: { - timestamp: "2025-11-10T15:30:00Z", - source: "weather-api" - } - } -} - -// View receives (JSON-RPC notification): -{ - jsonrpc: "2.0", - method: "ui/notifications/tool-result", - params: { - content: [ - { type: "text", text: "Current weather: Sunny, 72ยฐF" } - ], - structuredContent: { - temperature: 72, - conditions: "sunny", - humidity: 45 - }, - _meta: { - timestamp: "2025-11-10T15:30:00Z", - source: "weather-api" - } - } -} -``` - -**Best Practices:** - -- `content`: Text representation for model context and text-only hosts -- `structuredContent`: Structured data optimized for UI rendering (not added to model context) -- `_meta`: Additional metadata (timestamps, version info, etc.) not intended for model context - -#### 3. Interactive Updates - -Views can request fresh data by calling tools: - -```typescript -// View requests updated data -await client.callTool("get_weather", { location: "New York" }); - -// Result returned via standard tools/call response -``` - -This pattern enables interactive, self-updating views. - -Note: Tools with `visibility: ["app"]` are hidden from the agent but remain callable by apps via `tools/call`. This enables UI-only interactions (refresh buttons, form submissions) without exposing implementation details to the model. See the Visibility section under Resource Discovery for details. - -### Client\<\>Server Capability Negotiation - -Clients and servers negotiate MCP Apps support through the standard MCP extensions capability mechanism (defined in SEP-1724). - -#### Client (Host) Capabilities - -Clients advertise MCP Apps support in the initialize request using the extension identifier `io.modelcontextprotocol/ui`: - -```json -{ - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": { - "extensions": { - "io.modelcontextprotocol/ui": { - "mimeTypes": ["text/html;profile=mcp-app"] - } - } - }, - "clientInfo": { - "name": "claude-desktop", - "version": "1.0.0" - } - } -} -``` - -**Extension Settings:** - -- `mimeTypes`: Array of supported content types (REQUIRED, e.g., `["text/html;profile=mcp-app"]`) - -Future versions may add additional settings: - -- `features`: Specific feature support (e.g., `["streaming", "persistence"]`) -- `sandboxPolicies`: Supported sandbox attribute configurations - -#### Server Behavior - -Servers SHOULD check client capabilities before registering UI-enabled tools. The SDK provides the `getUiCapability` helper for this: - -```typescript -import { getUiCapability, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; - -const uiCap = getUiCapability(clientCapabilities); -if (uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) { - // Register tools with UI templates - server.registerTool("get_weather", { - description: "Get weather with interactive dashboard", - inputSchema: { /* ... */ }, - _meta: { - ui: { resourceUri: "ui://weather-server/dashboard" } - } - }); -} else { - // Register text-only version - server.registerTool("get_weather", { - description: "Get weather as text", - inputSchema: { /* ... */ } - // No UI metadata - }); -} -``` - -**Graceful Degradation:** - -- Servers SHOULD provide text-only fallback behavior for all UI-enabled tools -- Tools MUST return meaningful content array even when UI is available -- Servers MAY register different tool variants based on host capabilities - -### Extensibility - -This specification defines the Minimum Viable Product (MVP) for MCP Apps. Future extensions may include: - -**Content Types (deferred from MVP):** - -- `externalUrl`: Embed external web applications (e.g., `text/uri-list`) - -**Advanced Features (see Future Considerations):** - -- Support multiple UI resources in a tool response -- State persistence and restoration -- Custom sandbox policies per resource -- View-to-View communication -- Screenshot/preview generation APIs - -## Rationale - -This proposal synthesizes feedback from the UI CWG and MCP-UI community, host implementors, and lessons from similar solutions. The guiding principle of this proposal is to start lean and expand in the future. There are breaking changes from existing solutions, which will be addressed via the MCP-UI SDK during the migration period. - -### Design Decisions - -#### 1. Predeclared Resources vs. Inline Embedding - -**Decision:** Require UI resources to be registered and referenced in tool metadata. - -**Rationale:** - -- Enables hosts to prefetch templates before tool execution, improving performance -- Separates presentation (template) from data (tool results), improving caching -- Allows hosts to review UI templates -- Aligns with MCP's resource discovery pattern - -**Alternatives considered:** - -- **Embedded resources:** Current MCP-UI approach, where resources are returned in tool results. Although it's more convenient for server development, it was deferred due to the gaps in performance optimization and the challenges in the UI review process. -- **Resource links:** Predeclare the resources but return links in tool results. Deferred due to the gaps in performance optimization. - -#### 2. Host communication via MCP Transport - -**Decision:** Use MCP's JSON-RPC base protocol over postMessage instead of custom message format. - -**Rationale:** - -- Reuses existing MCP infrastructure (type definitions, error handling, timeouts) -- UI developers can use standard MCP SDK (`@modelcontextprotocol/sdk`) or alternatives -- Automatic compatibility with future MCP features (long-running tools, sampling, etc.) -- Better auditability through structured JSON-RPC messages -- Reduces maintenance burden (no parallel protocol to evolve) - -**Alternatives considered:** - -- **Custom message protocol:** Current MCP-UI approach with message types like tool, prompt, etc. These message types can be translated to a subset of the proposed JSON-RPC messages. -- **Global API object:** Rejected because it requires host-specific injection and doesn't work with external iframe sources. Syntactic sugar may still be added on the server/UI side. - -#### 3. Support Raw HTML Content Type - -**Decision:** MVP supports only `text/html;profile=mcp-app` (rawHtml), with other types explicitly deferred. - -**Rationale:** - -- HTML is universally supported and well-understood -- Simplest security model (standard iframe sandbox) -- Allows screenshot/preview generation (e.g., via html2canvas) -- Sufficient for most observed use cases -- Provides clear baseline for future extensions - -**Alternatives considered:** - -- **Include external URLs in MVP:** This is one of the easiest content types for servers to adopt, as it's possible to embed regular apps. However, it was deferred due to concerns around model visibility, inability to screenshot content, and review process. -- **Support multiple content types:** Deferred to maintain a lean MVP. - -#### 4. Host Theming via CSS Variables - -**Decision:** Provide a standardized set of CSS custom properties for visual cohesion. - -**Rationale:** - -- CSS variables are universal, framework-agnostic, and require no runtime -- Views apply styles via `var(--name)` with fallbacks for graceful degradation -- Limited variable set (colors, typography, borders) ensures hosts can realistically provide all values -- Spacing intentionally excludedโ€”layouts break when spacing varies from original design -- No UI component libraryโ€”no single library works across all host environments - -**Alternatives considered:** - -- **Full design system:** Rejected as too prescriptive; hosts have different aesthetics -- **Inline styles in tool results:** Rejected; separating theming from data enables caching and updates -- **CSS-in-JS injection:** Rejected; framework-specific and security concerns with injected code - -#### 5. Tool Visibility via Metadata - -**Decision:** Use `_meta.ui.visibility` array to control tool accessibility between model and app. - -**Rationale:** - -- Nested `_meta.ui` structure groups all UI-related metadata cleanly -- Array format (`["model", "app"]`) allows flexible combinations -- Default `["model", "app"]` allows both agent and app to access tools -- `"app"` scope is per-server, preventing cross-server tool calls -- Cleaner than OpenAI's two-field approach (`widgetAccessible` + `visibility`) - -**Alternatives considered:** - -- **Two separate fields:** OpenAI uses `widgetAccessible` and `visibility` separately. Rejected as redundant; single `visibility` array covers all cases. -- **Boolean `private` flag:** Simpler but less flexible; doesn't express model-only tools. -- **Flat `ui/visibility` key:** Rejected in favor of nested structure for consistency with future `_meta.ui` fields. - -### Backward Compatibility - -The proposal builds on the existing core protocol. There are no incompatibilities. - -### Reference Implementation - -The MCP-UI project serves as a reference implementation demonstrating the core concept, though it uses pre-SEP patterns. - -Olivier Chafik has developed a prototype demonstrating the pattern described in this SEP. - -## Security Implications - -Hosting interactive UI content from potentially untrusted MCP servers requires careful security consideration. - -### Threat Model - -Attackers may use the embedded UI in different scenarios. For example: - -- Malicious server delivers harmful HTML content -- Compromised View attempts to escape sandbox -- View attempts unauthorized tool execution -- View exfiltrates sensitive host data -- View performs phishing or social engineering - -### Mitigations - -#### 1. Iframe Sandboxing - -All View content MUST be rendered in sandboxed iframes with restricted permissions. - -The sandbox limits the View from accessing the host or manipulating it. All communication with the host is done via `postMessage`, where the host is in control. - -#### 2. Auditable Communication - -All View-to-host communication goes through auditable MCP JSON-RPC messages. - -**Host behavior:** - -- Validate all incoming messages from UI iframes -- Reject malformed message types -- Log View-initiated RPC calls for security review - -#### 3. Predeclared Resource Review - -Hosts receive UI templates during connection setup, before tool execution. - -**Host behavior:** - -- Review HTML content for obvious malicious patterns -- Generate hash/signature for resources -- Warn users about suspicious content -- Implement allowlists/blocklists based on resource hashes - -#### 4. Content Security Policy Enforcement - -Hosts MUST enforce Content Security Policies based on resource metadata. - -**CSP Construction from Metadata:** - -```typescript -const csp = resource._meta?.ui?.csp; // `resource` is extracted from the `contents` of the `resources/read` result -const permissions = resource._meta?.ui?.permissions; - -const cspValue = ` - default-src 'none'; - script-src 'self' 'unsafe-inline' ${csp?.resourceDomains?.join(' ') || ''}; - style-src 'self' 'unsafe-inline' ${csp?.resourceDomains?.join(' ') || ''}; - connect-src 'self' ${csp?.connectDomains?.join(' ') || ''}; - img-src 'self' data: ${csp?.resourceDomains?.join(' ') || ''}; - font-src 'self' ${csp?.resourceDomains?.join(' ') || ''}; - media-src 'self' data: ${csp?.resourceDomains?.join(' ') || ''}; - frame-src ${csp?.frameDomains?.join(' ') || "'none'"}; - object-src 'none'; - base-uri ${csp?.baseUriDomains?.join(' ') || "'self'"}; -`; - -// Permission Policy for iframe allow attribute -const allowList: string[] = []; -if (permissions?.camera) allowList.push('camera'); -if (permissions?.microphone) allowList.push('microphone'); -if (permissions?.geolocation) allowList.push('geolocation'); -const allowAttribute = allowList.join(' '); -``` - -**Security Requirements:** - -- Host MUST block connections to undeclared domains -- Host SHOULD warn users when UI requires external domain access -- Host MAY implement global domain allowlists/blocklists - -### Other risks - -- **Social engineering:** UI can still display misleading content. Hosts should clearly indicate sandboxed UI boundaries. -- **Resource consumption:** Malicious View can consume CPU/memory. Hosts should implement resource limits. - -## Reservations in MCP - -- The resource prefix `ui://` will be reserved for MCP Apps -- The label `io.modelcontextprotocol/ui` is reserved diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index f2523f2..59eaa02 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -174,44 +174,36 @@ interface UIResourceMeta { * * Maps to Permission Policy `camera` feature */ - camera?: {}, + camera?: boolean, /** * Request microphone access * * Maps to Permission Policy `microphone` feature */ - microphone?: {}, + microphone?: boolean, /** * Request geolocation access * * Maps to Permission Policy `geolocation` feature */ - geolocation?: {}, + geolocation?: boolean, /** * Request clipboard write access * * Maps to Permission Policy `clipboard-write` feature */ - clipboardWrite?: {}, + clipboardWrite?: boolean, }, /** - * Dedicated origin for view + * Dedicated origin for widget * - * Optional domain for the view's sandbox origin. Useful when views need - * stable, dedicated origins for OAuth callbacks, CORS policies, or API key allowlists. + * Optional domain for the widget's sandbox origin. Useful when widgets need + * dedicated origins for API key allowlists or cross-origin isolation. * - * **Host-dependent:** The format and validation rules for this field are - * determined by each host. Servers MUST consult host-specific documentation - * for the expected domain format. Common patterns include: - * - Hash-based subdomains (e.g., `{hash}.claudemcpcontent.com`) - * - URL-derived subdomains (e.g., `www-example-com.oaiusercontent.com`) - * - * If omitted, Host uses default sandbox origin (typically per-conversation). + * If omitted, Host uses default sandbox origin. * * @example - * "a904794854a047f6.claudemcpcontent.com" - * @example - * "www-example-com.oaiusercontent.com" + * "https://weather-widget.example.com" */ domain?: string, /** @@ -247,10 +239,10 @@ The resource content is returned via `resources/read`: baseUriDomains?: string[]; // Allowed base URIs for the document (base-uri directive). }; permissions?: { - camera?: {}; // Request camera access - microphone?: {}; // Request microphone access - geolocation?: {}; // Request geolocation access - clipboardWrite?: {}; // Request clipboard write access + camera?: boolean; // Request camera access + microphone?: boolean; // Request microphone access + geolocation?: boolean; // Request geolocation access + clipboardWrite?: boolean; // Request clipboard write access }; domain?: string; prefersBorder?: boolean; @@ -260,17 +252,6 @@ The resource content is returned via `resources/read`: } ``` -#### Metadata Location - -`UIResourceMeta` (CSP, permissions, domain, prefersBorder) may be provided on either or both: - -- **`resources/list`:** On the resource entry's `_meta.ui` field. Useful as a static default that hosts can review at connection time. -- **`resources/read`:** On each content item's `_meta.ui` field. Useful for per-response overrides or dynamic metadata that is only known at read time. - -When `_meta.ui` is present on **both**, the content-item value takes precedence. Hosts MUST check both locations, preferring the content item and falling back to the listing entry. - -> **Server guidance:** Prefer placing `_meta.ui` on the content item in `resources/read`, especially when metadata is dynamic or varies per-response. Use the listing-level `_meta.ui` (via `registerAppResource` config) when metadata is static and you want hosts to be able to review security configuration at connection time without fetching the resource. - #### Content Requirements: - URI MUST start with `ui://` scheme @@ -289,7 +270,6 @@ When `_meta.ui` is present on **both**, the content-item value takes precedence. style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self' data:; - object-src 'none'; connect-src 'none'; ``` @@ -299,24 +279,15 @@ When `_meta.ui` is present on **both**, the content-item value takes precedence. Example: ```json -// Resource declaration (resources/list) โ€” static defaults for host review +// Resource declaration { "uri": "ui://weather-server/dashboard-template", "name": "weather_dashboard", - "description": "Interactive weather dashboard view", - "mimeType": "text/html;profile=mcp-app", - "_meta": { - "ui": { - "csp": { - "connectDomains": ["https://api.openweathermap.org"], - "resourceDomains": ["https://cdn.jsdelivr.net"] - }, - "prefersBorder": true - } - } + "description": "Interactive weather dashboard widget", + "mimeType": "text/html;profile=mcp-app" } -// Resource content (resources/read) โ€” takes precedence when present +// Resource content with metadata { "contents": [{ "uri": "ui://weather-server/dashboard-template", @@ -438,7 +409,7 @@ Conceptually, UI iframes act as MCP clients, connecting to the host via a `postM ```typescript // UI iframe initializes MCP client const transport = new MessageTransport(window.parent); -const client = new Client({ name: "ui-view", version: "1.0.0" }); +const client = new Client({ name: "ui-widget", version: "1.0.0" }); await client.connect(transport); ``` @@ -488,51 +459,35 @@ Hosts act as MCP servers (that can proxy the actual MCP server), receiving and h ### Sandbox proxy -If the Host is a web page, it MUST wrap the View and communicate with it through an intermediate Sandbox proxy. +If the Host is a web page, it MUST wrap the Guest UI and communicate with it through an intermediate Sandbox proxy. 1. The Host and the Sandbox MUST have different origins. 2. The Sandbox MUST have the following permissions: `allow-scripts`, `allow-same-origin`. -3. The Sandbox SHOULD send a `ui/notifications/sandbox-proxy-ready` notification to the host when it's ready to process an `ui/notifications/sandbox-resource-ready` notification. -4. Once the Sandbox is ready, the Host SHOULD send the raw HTML resource to load in a `ui/notifications/sandbox-resource-ready` notification. -5. The Sandbox MUST load the raw HTML of the View with CSP settings that: +3. The Sandbox MUST send a `ui/notifications/sandbox-proxy-ready` notification to the host when it's ready to process an `ui/notifications/sandbox-resource-ready` notification. +4. Once the Sandbox is ready, the Host MUST send the raw HTML resource to load in a `ui/notifications/sandbox-resource-ready` notification. +5. The Sandbox MUST load the raw HTML of the Guest UI with CSP settings that: - Enforce the domains declared in `ui.csp` metadata - If `frameDomains` is provided, allow nested iframes from declared origins; otherwise use `frame-src 'none'` - If `baseUriDomains` is provided, allow base URIs from declared origins; otherwise use `base-uri 'self'` - Block dangerous features (`object-src 'none'`) - Apply restrictive defaults if no CSP metadata is provided - If `permissions` is declared, the Sandbox MAY set the inner iframe's `allow` attribute accordingly -6. The Sandbox MUST forward messages sent by the Host to the View, and vice versa, for any method that doesn't start with `ui/notifications/sandbox-`. This includes lifecycle messages, e.g., `ui/initialize` request & `ui/notifications/initialized` notification both sent by the View. The Host MUST NOT send any request or notification to the View before it receives an `initialized` notification. -7. The Sandbox SHOULD NOT create/send any requests to the Host or to the View (this would require synthesizing new request ids). -8. The Host MAY forward any message from the View (coming via the Sandbox) to the MCP Apps server, for any method that doesn't start with `ui/`. While the Host SHOULD ensure the View's MCP connection is spec-compliant, it MAY decide to block some messages or subject them to further user approval. +6. The Sandbox MUST forward messages sent by the Host to the Guest UI, and vice versa, for any method that doesnโ€™t start with `ui/notifications/sandbox-`. This includes lifecycle messages, e.g., `ui/initialize` request & `ui/notifications/initialized` notification both sent by the Guest UI. The Host MUST NOT send any request or notification to the Guest UI before it receives an `initialized` notification. +7. The Sandbox SHOULD NOT create/send any requests to the Host or to the Guest UI (this would require synthesizing new request ids). +8. The Host MAY forward any message from the Guest UI (coming via the Sandbox) to the MCP Apps server, for any method that doesnโ€™t start with `ui/`. While the Host SHOULD ensure the Guest UIโ€™s MCP connection is spec-compliant, it MAY decide to block some messages or subject them to further user approval. ### Standard MCP Messages -UI iframes can use the following subset of standard MCP protocol messages. - -Note that `tools/call` and `tools/list` flow **bidirectionally**: -- **App โ†’ Host โ†’ Server**: Apps call server tools (requires host `serverTools` capability) -- **Host โ†’ App**: Host calls app-registered tools (requires app `tools` capability) +UI iframes can use the following subset of standard MCP protocol messages: **Tools:** -- `tools/call` - Execute a tool (bidirectional) - - **App โ†’ Host**: Call server tool via host proxy - - **Host โ†’ App**: Call app-registered tool -- `tools/list` - List available tools (bidirectional) - - **App โ†’ Host**: List server tools - - **Host โ†’ App**: List app-registered tools -- `notifications/tools/list_changed` - Notify when tool list changes (bidirectional) - - **Server โ†’ Host โ†’ App**: Server tools changed - - **App โ†’ Host**: App-registered tools changed +- `tools/call` - Execute a tool on the MCP server **Resources:** - `resources/read` - Read resource content -**Sampling:** - -- `sampling/createMessage` - Request an LLM completion from the host (uses the standard MCP [`CreateMessageRequest`](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) / `CreateMessageResult` types, including SEP-1577 `tools` / `toolChoice` / `tool_use` content blocks). The host has full discretion over model selection and SHOULD apply rate limiting, cost controls, and user approval (human-in-the-loop). Apps MUST check `hostCapabilities.sampling` before sending this request, and `hostCapabilities.sampling.tools` before including `tools` in the request params. - **Notifications:** - `notifications/message` - Log messages to host @@ -542,34 +497,13 @@ Note that `tools/call` and `tools/list` flow **bidirectionally**: - `ui/initialize` โ†’ `ui/notifications/initialized` - MCP-like handshake (replaces custom iframe-ready pattern in MCP-UI) - `ping` - Connection health check -### App Capabilities in `ui/initialize` - -When the View sends an `ui/initialize` request to the Host, it MUST include its capabilities in the `appCapabilities` field: - -```typescript -interface McpUiAppCapabilities { - /** Experimental features keyed by identifier. */ - experimental?: Record<string, object>; - /** App exposes MCP-style tools that the host can call. */ - tools?: { - /** App supports tools/list_changed notifications. */ - listChanged?: boolean; - }; - /** - * Display modes the app supports. See Display Modes section for details. - * @example ["inline", "fullscreen"] - */ - availableDisplayModes?: Array<"inline" | "fullscreen" | "pip">; -} -``` - ### Host Context in `McpUiInitializeResult` -When the View sends an `ui/initialize` request to the Host, the Host SHOULD include UI-specific context in the `McpUiInitializeResult`'s `hostContext` field: +When the Guest UI sends an `ui/initialize` request to the Host, the Host SHOULD include UI-specific context in the `McpUiInitializeResult`'s `hostContext` field: ```typescript interface HostContext { - /** Metadata of the tool call that instantiated the View */ + /** Metadata of the tool call that instantiated the App */ toolInfo?: { /** JSON-RPC id of the tools/call request */ id?: RequestId, @@ -582,23 +516,23 @@ interface HostContext { styles?: { /** CSS variables for theming */ variables?: Record<McpUiStyleVariableKey, string | undefined>; - /** CSS blocks that Views can inject */ + /** CSS blocks that apps can inject */ css?: { /** CSS for font loading (@font-face rules or @import statements) */ fonts?: string; }; }; - /** How the View is currently displayed */ + /** How the UI is currently displayed */ displayMode?: "inline" | "fullscreen" | "pip"; /** Display modes the host supports */ availableDisplayModes?: string[]; /** Container dimensions for the iframe. Specify either width or maxWidth, and either height or maxHeight. */ containerDimensions?: ( | { height: number } // If specified, container is fixed at this height - | { maxHeight?: number } // Otherwise, container height is determined by the View's height, up to this maximum height (if defined) + | { maxHeight?: number } // Otherwise, container height is determined by the UI height, up to this maximum height (if defined) ) & ( | { width: number } // If specified, container is fixed at this width - | { maxWidth?: number } // Otherwise, container width is determined by the View's width, up to this maximum width (if defined) + | { maxWidth?: number } // Otherwise, container width is determined by the UI width, up to this maximum width (if defined) ); /** User's language/region preference (BCP 47, e.g., "en-US") */ locale?: string; @@ -656,33 +590,15 @@ Example: ### Host Capabilities -`HostCapabilities` are sent to the View as part of the response to `ui/initialize` (inside `McpUiInitializeResult`). +`HostCapabilities` are sent to the Guest UI as part of the response to `ui/initialize` (inside `McpUiInitializeResult`). They describe the features and capabilities that the Host supports. ```typescript -/** Content block modalities that the host supports for a given capability. */ -interface SupportedContentBlockModalities { - /** Host supports text content blocks. */ - text?: {}; - /** Host supports image content blocks. */ - image?: {}; - /** Host supports audio content blocks. */ - audio?: {}; - /** Host supports resource content blocks. */ - resource?: {}; - /** Host supports resource link content blocks. */ - resourceLink?: {}; - /** Host supports structured content. */ - structuredContent?: {}; -} - interface HostCapabilities { - /** Experimental features keyed by identifier. */ - experimental?: Record<string, object>; + /** Experimental features (structure TBD). */ + experimental?: {}; /** Host supports opening external URLs. */ openLinks?: {}; - /** Host supports file downloads via ui/download-file. */ - downloadFile?: {}; /** Host can proxy tool calls to the MCP server. */ serverTools?: { /** Host supports tools/list_changed notifications. */ @@ -699,10 +615,10 @@ interface HostCapabilities { sandbox?: { /** Permissions granted by the host (camera, microphone, geolocation, clipboard-write). */ permissions?: { - camera?: {}; - microphone?: {}; - geolocation?: {}; - clipboardWrite?: {}; + camera?: boolean; + microphone?: boolean; + geolocation?: boolean; + clipboardWrite?: boolean; }; /** CSP domains approved by the host. */ csp?: { @@ -716,21 +632,6 @@ interface HostCapabilities { baseUriDomains?: string[]; }; }; - /** - * Host accepts context updates (ui/update-model-context) to be included in - * the model's context for future turns. - */ - updateModelContext?: SupportedContentBlockModalities; - /** Host supports receiving content messages (ui/message) from the view. */ - message?: SupportedContentBlockModalities; - /** - * Host supports LLM sampling (sampling/createMessage) from the view. - * Mirrors MCP ClientCapabilities.sampling so hosts can pass it through. - */ - sampling?: { - /** Host supports tool use via `tools` and `toolChoice` params (SEP-1577). */ - tools?: {}; - }; } ``` @@ -738,24 +639,24 @@ interface HostCapabilities { The `HostContext` provides sizing information via `containerDimensions`: -- **`containerDimensions`**: The dimensions of the container that holds the View. This controls the actual space the View occupies within the host. Each dimension (height and width) operates independently and can be either **fixed** or **flexible**. +- **`containerDimensions`**: The dimensions of the container that holds the app. This controls the actual space the app occupies within the host. Each dimension (height and width) operates independently and can be either **fixed** or **flexible**. #### Dimension Modes | Mode | Dimensions Field | Meaning | |------|-----------------|---------| -| Fixed | `height` or `width` | Host controls the size. View should fill the available space. | -| Flexible | `maxHeight` or `maxWidth` | View controls the size, up to the specified maximum. | -| Unbounded | Field omitted | View controls the size with no limit. | +| Fixed | `height` or `width` | Host controls the size. App should fill the available space. | +| Flexible | `maxHeight` or `maxWidth` | App controls the size, up to the specified maximum. | +| Unbounded | Field omitted | App controls the size with no limit. | -These modes can be combined independently. For example, a host might specify a fixed width but flexible height, allowing the View to grow vertically based on content. +These modes can be combined independently. For example, a host might specify a fixed width but flexible height, allowing the app to grow vertically based on content. -#### View Behavior +#### App Behavior -Views should check the containerDimensions configuration and apply appropriate CSS: +Apps should check the containerDimensions configuration and apply appropriate CSS: ```typescript -// In the View's initialization +// In the app's initialization const containerDimensions = hostContext.containerDimensions; if (containerDimensions) { @@ -783,12 +684,12 @@ if (containerDimensions) { #### Host Behavior -When using flexible dimensions (no fixed `height` or `width`), hosts MUST listen for `ui/notifications/size-changed` notifications from the View and update the iframe dimensions accordingly: +When using flexible dimensions (no fixed `height` or `width`), hosts MUST listen for `ui/notifications/size-changed` notifications from the app and update the iframe dimensions accordingly: ```typescript -// Host listens for size changes from the View +// Host listens for size changes from the app bridge.onsizechange = ({ width, height }) => { - // Update iframe to match View's content size + // Update iframe to match app's content size if (width != null) { iframe.style.width = `${width}px`; } @@ -798,63 +699,7 @@ bridge.onsizechange = ({ width, height }) => { }; ``` -Views using the SDK automatically send size-changed notifications via ResizeObserver when `autoResize` is enabled (the default). The notifications are debounced and only sent when dimensions actually change. - -### Display Modes - -Views can be displayed in different modes depending on the host's capabilities and the view's declared support. - -```typescript -type McpUiDisplayMode = "inline" | "fullscreen" | "pip"; -``` - -- **inline**: Default mode, embedded within the host's content flow -- **fullscreen**: View takes over the full screen/window -- **pip**: Picture-in-picture, floating overlay - -#### Declaring Support - -**View (`appCapabilities.availableDisplayModes`):** - -Views declare which display modes they support in the `ui/initialize` request via `appCapabilities.availableDisplayModes`. This allows hosts to only offer display mode options that the view can handle. - -```typescript -// Example: View declares support for inline and fullscreen -{ - method: "ui/initialize", - params: { - appCapabilities: { - availableDisplayModes: ["inline", "fullscreen"] - }, - // ... - } -} -``` - -**Host (`HostContext.availableDisplayModes`):** - -Hosts declare which display modes they support in `HostContext.availableDisplayModes`. Views should check this before requesting a mode change. - -#### Requesting Changes - -Views request display mode changes via `ui/request-display-mode`. See the Requests section for message format. - -#### Notifying Changes - -Hosts notify views of display mode changes via `ui/notifications/host-context-changed` with the `displayMode` field. - -#### Requirements - -**View behavior:** -- View MUST declare all display modes it supports in `appCapabilities.availableDisplayModes` during initialization. -- View MUST check if the requested mode is in `availableDisplayModes` from host context before requesting a mode change. -- View MUST handle the response mode differing from the requested mode. - -**Host behavior:** -- Host MUST NOT switch the View to a display mode that does not appear in its `appCapabilities.availableDisplayModes`, if set. -- Host MUST return the resulting mode (whether updated or not) in the response to `ui/request-display-mode`. -- If the requested mode is not available, Host SHOULD return the current display mode in the response. -- Host MAY decline display mode requests from Views that did not declare said modes in their capabilities. +Apps using the SDK automatically send size-changed notifications via ResizeObserver when `autoResize` is enabled (the default). The notifications are debounced and only sent when dimensions actually change. ### Theming @@ -863,7 +708,7 @@ Hosts can optionally pass CSS custom properties via `HostContext.styles.variable #### Current Standardized Variables ```typescript -/** CSS variable keys available to Views for theming. */ +/** CSS variable keys available to MCP apps for theming. */ type McpUiStyleVariableKey = // Background colors | "--color-background-primary" @@ -959,12 +804,12 @@ type McpUiStyleVariableKey = #### Host Behavior - Hosts can provide any subset of standardized variables, or not pass `styles` at all - - However, unexpected clashes may occur if hosts pass some color variables but not others for example, since Views are instructed to fallback on their own default values for unspecified style variables + - However, unexpected clashes may occur if hosts pass some color variables but not others for example, since apps are instructed to fallback on their own default values for unspecified style variables - Hosts should use the CSS `light-dark()` function for theme-aware values (i.e. light mode and dark mode colors) -#### View Behavior +#### App Behavior -- Views should set default fallback values for the set of these variables that they use, to account for hosts who don't pass some or all style variables. This ensures graceful degradation when hosts omit `styles` or specific variables: +- Apps should set default fallback values for the set of these variables that they use, to account for hosts who don't pass some or all style variables. This ensures graceful degradation when hosts omit `styles` or specific variables: ``` :root { --color-text-primary: light-dark(#171717, #000000); @@ -972,8 +817,8 @@ type McpUiStyleVariableKey = ... } ``` -- Views can use the `applyHostStyleVariables` utility (or `useHostStyleVariables` if they prefer a React hook) to easily populate the host-provided CSS variables into their style sheet -- Views can use the `applyDocumentTheme` utility (or `useDocumentTheme` if they prefer a React hook) to easily respond to Host Context `theme` changes in a way that is compatible with the host's light/dark color variables +- Apps can use the `applyHostStyleVariables` utility (or `useHostStyleVariables` if they prefer a React hook) to easily populate the host-provided CSS variables into their style sheet +- Apps can use the `applyDocumentTheme` utility (or `useDocumentTheme` if they prefer a React hook) to easily respond to Host Context `theme` changes in a way that is compatible with the host's light/dark color variables Example usage of standardized CSS variables: @@ -1016,7 +861,7 @@ hostContext.styles.css.fonts = ` `; ``` -Views can use the `applyHostFonts` utility to inject the font CSS into the document: +Apps can use the `applyHostFonts` utility to inject the font CSS into the document: ```typescript if (hostContext.styles?.css?.fonts) { @@ -1028,7 +873,7 @@ if (hostContext.styles?.css?.fonts) { MCP Apps introduces additional JSON-RPC methods for UI-specific functionality: -#### Requests (View โ†’ Host) +#### Requests (UI โ†’ Host) `ui/open-link` - Request host to open external URL @@ -1063,73 +908,6 @@ MCP Apps introduces additional JSON-RPC methods for UI-specific functionality: Host SHOULD open the URL in the user's default browser or a new tab. -`ui/download-file` - Request host to download a file - -```typescript -// Request (EmbeddedResource โ€” inline content) -{ - jsonrpc: "2.0", - id: 1, - method: "ui/download-file", - params: { - contents: [ - { - type: "resource", - resource: { - uri: "file:///export.json", // Used for suggested filename - mimeType: "application/json", - text: "{ ... }" // Text content (or `blob` for base64 binary) - } - } - ] - } -} - -// Request (ResourceLink โ€” host fetches) -{ - jsonrpc: "2.0", - id: 1, - method: "ui/download-file", - params: { - contents: [ - { - type: "resource_link", - uri: "https://api.example.com/reports/q4.pdf", - name: "Q4 Report", - mimeType: "application/pdf" - } - ] - } -} - -// Success Response -{ - jsonrpc: "2.0", - id: 1, - result: {} // Empty result on success -} - -// Error Response (if denied or failed) -{ - jsonrpc: "2.0", - id: 1, - error: { - code: -32000, // Implementation-defined error - message: "Download denied by user" | "Invalid content" | "Policy violation" - } -} -``` - -MCP Apps run in sandboxed iframes where direct file downloads are blocked (`allow-downloads` is not set). `ui/download-file` provides a host-mediated mechanism for apps to offer file exports โ€” useful for visualization tools (SVG/PNG export), document editors, data analysis tools, and any app that produces downloadable artifacts. - -The `contents` array uses standard MCP resource types (`EmbeddedResource` and `ResourceLink`), avoiding custom content formats. For `EmbeddedResource`, content is inline via `text` (UTF-8) or `blob` (base64). For `ResourceLink`, the host can retrieve the content directly from the URI. - -Host behavior: -* Host SHOULD show a confirmation dialog before initiating the download. -* For `EmbeddedResource`, host SHOULD derive the filename from the last segment of `resource.uri`. -* Host MAY reject the download based on security policy, file size limits, or user preferences. -* Host SHOULD sanitize filenames to prevent path traversal. - `ui/message` - Send message content to the host's chat interface ```typescript @@ -1191,7 +969,15 @@ Host behavior: } ``` -See the Display Modes section for detailed behavior requirements. +Host behavior: +* App MUST check if the requested mode is in `availableDisplayModes` from host context. +* It is up to the host whether it switches to the requested mode, but the host MUST return the resulting mode (whether updated or not) in the response. +* If the requested mode is not available, Host SHOULD return the current display mode in the response. +* Host MAY coerce modes on certain platforms (e.g., "pip" to "fullscreen" on mobile). + +Guest UI behavior: +* Guest UI SHOULD check `availableDisplayModes` in host context before requesting a mode change. +* Guest UI MUST handle the response mode differing from the requested mode. `ui/update-model-context` - Update the model context @@ -1225,7 +1011,7 @@ See the Display Modes section for detailed behavior requirements. } ``` -The View MAY send this request to update the Host's model context. This context will be used in future turns. Each request overwrites the previous context sent by the View. +Guest UI MAY send this request to update the Host's model context. This context will be used in future turns. Each request overwrites the previous context sent by the Guest UI. This event serves a different use case from `notifications/message` (logging) and `ui/message` (which also trigger follow-ups). Host behavior: @@ -1236,98 +1022,9 @@ Host behavior: - If multiple updates are received before the next user message, Host SHOULD only send the last update to the model - MAY display context updates to the user -#### Requests (Host โ†’ App) +#### Notifications (Host โ†’ UI) -When Apps declare the `tools` capability, the Host can send standard MCP tool requests to the App: - -`tools/call` - Execute an App-registered tool - -```typescript -// Request (Host โ†’ App) -{ - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { - name: string, // Name of app-registered tool to execute - arguments?: object // Tool arguments (validated against inputSchema) - } -} - -// Success Response (App โ†’ Host) -{ - jsonrpc: "2.0", - id: 1, - result: { - content: Array<ContentBlock>, // Result for model context - structuredContent?: object, // Optional structured data for UI - isError?: boolean, // True if tool execution failed - _meta?: object // Optional metadata - } -} - -// Error Response -{ - jsonrpc: "2.0", - id: 1, - error: { - code: number, - message: string - } -} -``` - -**App Behavior:** -- Apps MUST implement `oncalltool` handler if they declare `tools` capability -- Apps SHOULD validate tool names and arguments -- Apps MAY use `app.registerTool()` SDK helper for automatic validation -- Apps SHOULD return `isError: true` for tool execution failures - -`tools/list` - List App-registered tools - -```typescript -// Request (Host โ†’ App) -{ - jsonrpc: "2.0", - id: 2, - method: "tools/list", - params: { - cursor?: string // Optional pagination cursor - } -} - -// Response (App โ†’ Host) -{ - jsonrpc: "2.0", - id: 2, - result: { - tools: Array<Tool>, // List of available tools - nextCursor?: string // Pagination cursor if more tools exist - } -} -``` - -**Tool Structure** (identical to [core MCP `Tool`](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool)): -```typescript -interface Tool { - name: string; // Unique tool identifier - title?: string; // Display name - description?: string; // Human-readable description - inputSchema: object; // JSON Schema for arguments - outputSchema?: object; // JSON Schema for structuredContent - annotations?: ToolAnnotations; // MCP tool annotations (e.g., readOnlyHint) - _meta?: object; -} -``` - -**App Behavior:** -- Apps MUST implement `onlisttools` handler if they declare `tools` capability -- Apps SHOULD return complete tool metadata including schemas -- Apps MAY filter tools based on context or permissions - -#### Notifications (Host โ†’ View) - -`ui/notifications/tool-input` - Host MUST send this notification with the complete tool arguments after the View's initialize request completes. +`ui/notifications/tool-input` - Host MUST send this notification with the complete tool arguments after the Guest UI's initialize request completes. ```typescript { @@ -1339,7 +1036,7 @@ interface Tool { } ``` -Host sends this notification after the View's initialize request completes, when tool arguments become available. This notification is sent at most once and is required before sending `ui/notifications/tool-result`. +Host sends this notification after the Guest UI's initialize request completes, when tool arguments become available. This notification is sent at most once and is required before sending `ui/notifications/tool-result`. `ui/notifications/tool-input-partial` - Host MAY send this notification zero or more times while the agent is streaming tool arguments, before `ui/notifications/tool-input` is sent. @@ -1359,7 +1056,7 @@ The arguments object represents best-effort recovery of incomplete JSON, with un - MAY send recovered arguments as they become available during streaming - MUST stop sending once `ui/notifications/tool-input` is sent with complete arguments -View behavior (optional): +Guest UI behavior (optional): - MAY ignore these notifications entirely - MAY render progressive loading/streaming states based on available fields @@ -1376,7 +1073,7 @@ View behavior (optional): } ``` -Host MUST send this notification when tool execution completes (if the View is displayed during tool execution). +Host MUST send this notification when tool execution completes (if UI is displayed during tool execution). `ui/notifications/tool-cancelled` - Tool execution was cancelled @@ -1392,7 +1089,7 @@ Host MUST send this notification when tool execution completes (if the View is d Host MUST send this notification if the tool execution was cancelled, for any reason (which can optionally be specified), including user action, sampling error, classifier intervention, etc. -`ui/resource-teardown` - Host notifies View before teardown +`ui/resource-teardown` - Host notifies UI before teardown ```typescript { @@ -1422,12 +1119,10 @@ Host MUST send this notification if the tool execution was cancelled, for any re } ``` -Host MUST send this notification before tearing down the UI resource, for any reason, including user action, view-initiated teardown, resource re-allocation, etc. The Host MAY specify the reason. +Host MUST send this notification before tearing down the UI resource, for any reason, including user action, resource re-allocation, etc. The Host MAY specify the reason. Host SHOULD wait for a response before tearing down the resource (to prevent data loss). -#### Notifications (View โ†’ Host) - -`ui/notifications/size-changed` - View's size changed +`ui/notifications/size-changed` - UIโ€™s size changed ```typescript { @@ -1440,23 +1135,7 @@ Host SHOULD wait for a response before tearing down the resource (to prevent dat } ``` -The View SHOULD send this notification when rendered content body size changes (e.g. using ResizeObserver API to report up to date size). - -`ui/notifications/request-teardown` - View requests host to tear it down - -```typescript -{ - jsonrpc: "2.0", - method: "ui/notifications/request-teardown", - params: {} -} -``` - -The View MAY send this notification to request that the host tear it down. This enables View-initiated teardown flows (e.g., user clicks a "Done" button in the View). - -**Host behavior:** -- Host MAY defer or ignore the teardown request. -- If the Host accepts the request, it MUST follow the graceful termination process by sending `ui/resource-teardown` to the View. The Host SHOULD wait for a response before tearing down the resource (to prevent data loss). +Guest UI SHOULD send this notification when rendered content body size changes (e.g. using ResizeObserver API to report up to date size). `ui/notifications/host-context-changed` - Host context has changed @@ -1468,7 +1147,7 @@ The View MAY send this notification to request that the host tear it down. This } ``` -Host MAY send this notification when any context field changes (e.g., theme toggle, display mode change, device orientation change, window/panel resize). This notification contains partial updates - the View SHOULD merge received fields with its current context state. +Host MAY send this notification when any context field changes (e.g., theme toggle, display mode change, device orientation change, window/panel resize). This notification contains partial updates - Guest UI SHOULD merge received fields with its current context state. #### Reserved Messages (Sandbox Proxy) @@ -1500,10 +1179,10 @@ These messages are reserved for web-based hosts that implement the recommended d baseUriDomains?: string[], }, permissions?: { // Sandbox permissions from resource metadata - camera?: {}, - microphone?: {}, - geolocation?: {}, - clipboardWrite?: {}, + camera?: boolean, + microphone?: boolean, + geolocation?: boolean, + clipboardWrite?: boolean, } } } @@ -1532,7 +1211,7 @@ sequenceDiagram ```mermaid sequenceDiagram participant H as Host - participant UI as View (iframe) + participant UI as Guest UI (iframe) participant P as Sandbox Proxy participant S as MCP Server @@ -1541,7 +1220,7 @@ sequenceDiagram H ->> S: tools/call to Tool with _meta.ui metadata and UI initialization alt Desktop/Native hosts - H ->> H: Render View in an iframe (HTML from the ui:// resource) + H ->> H: Render Guest UI in an iframe (HTML from the ui:// resource) else Web hosts H ->> H: Render Sandbox Proxy in an iframe (different origin) P ->> H: ui/notifications/sandbox-proxy-ready @@ -1564,7 +1243,7 @@ sequenceDiagram end ``` -Note: when the View is rendered inside a sandbox, the sandbox transparently passes messages between the View and the Host, except for messages named `ui/notifications/sandbox-*`. +Note: when the Guest UI is rendered inside a sandbox, the sandbox transparently passes messages between the Guest UI and the Host, except for messages named `ui/notifications/sandbox-*`. #### 3. Interactive Phase @@ -1572,7 +1251,7 @@ Note: when the View is rendered inside a sandbox, the sandbox transparently pass sequenceDiagram actor U as User / Agent participant H as Host - participant UI as View (iframe) + participant UI as Guest UI (iframe) participant S as MCP Server loop Interactive phase U ->> UI: interaction (e.g., click) @@ -1601,7 +1280,7 @@ sequenceDiagram S --> H: resources/read response H --> UI: resources/read response end - opt View notifications + opt UI notifications UI ->> H: notifications (e.g., ui/notifications/size-changed) end opt Host notifications @@ -1610,24 +1289,19 @@ sequenceDiagram end ``` -#### 4. Teardown - -The Host can tear down Views. Views may request teardown by sending `ui/notifications/request-teardown` to the Host. In any case, the Host MUST send `ui/resource-teardown` to allow the View to terminate gracefully. +#### 4. Cleanup ```mermaid sequenceDiagram participant H as Host - participant UI as View (iframe) - opt View-initiated teardown - UI ->> H: ui/notifications/request-teardown - end + participant UI as Guest UI (iframe) H ->> UI: ui/resource-teardown UI --> UI: Graceful termination UI -->> H: ui/resource-teardown response H -x H: Tear down iframe and listeners ``` -Note: Teardown may be triggered at any point in the lifecycle following View initialization. +Note: Cleanup may be triggered at any point in the lifecycle following UI initialization. #### Key Differences from Pre-SEP MCP-UI: @@ -1637,7 +1311,7 @@ Note: Teardown may be triggered at any point in the lifecycle following View ini ### Data Passing -Tool execution results are passed to the View through two mechanisms: +Tool execution results are passed to the UI through two mechanisms: #### 1. Tool Input (via `ui/notifications/tool-input` notification) @@ -1657,7 +1331,7 @@ The original tool call arguments: } } -// View receives (JSON-RPC notification from Host to View): +// UI receives (JSON-RPC notification from Host to UI): { jsonrpc: "2.0", method: "ui/notifications/tool-input", @@ -1694,7 +1368,7 @@ The tool's execution result: } } -// View receives (JSON-RPC notification): +// UI receives (JSON-RPC notification): { jsonrpc: "2.0", method: "ui/notifications/tool-result", @@ -1723,459 +1397,19 @@ The tool's execution result: #### 3. Interactive Updates -Views can request fresh data by calling tools: +UI can request fresh data by calling tools: ```typescript -// View requests updated data +// UI requests updated data await client.callTool("get_weather", { location: "New York" }); // Result returned via standard tools/call response ``` -This pattern enables interactive, self-updating views. +This pattern enables interactive, self-updating widgets. Note: Tools with `visibility: ["app"]` are hidden from the agent but remain callable by apps via `tools/call`. This enables UI-only interactions (refresh buttons, form submissions) without exposing implementation details to the model. See the Visibility section under Resource Discovery for details. -### App-Provided Tools - -Apps can register their own tools that hosts and agents can call, making apps **introspectable and accessible** to the model. This complements the existing capability where apps call server tools (via host proxy). - -#### Motivation: Semantic Introspection - -Without tool registration, apps are opaque to the model: -- The host renders the app in a sandboxed iframe and has no guaranteed access to its internals โ€” apps may embed further cross-origin iframes (via `frameDomains`), and even where same-origin, DOM access is not part of the hostโ†”app contract -- The model therefore cannot query app state or discover what operations the app supports - -With tool registration, apps expose a semantic interface: -- The model discovers available operations via `tools/list` -- The model queries app state via tools (e.g., `get_board_state`) -- The model executes actions via tools (e.g., `make_move`) -- Apps return structured data rather than relying on the host to interpret rendered output - -This is *pull*-based and complements the *push*-based [`ui/update-model-context`](#uiupdate-model-context) request already defined in this spec (and analogous mechanisms elsewhere, e.g. OAI Apps SDK's [`setWidgetState()`](https://developers.openai.com/apps-sdk/reference/sdk#setwidgetstate)). Push lets an app proactively keep the model's context current; pull lets the agent query and command the app on demand. Apps may use either or both. - -#### App Tool Registration - -Apps register tools using the SDK's `registerTool()` method: - -```typescript -import { App } from '@modelcontextprotocol/ext-apps'; -import { z } from 'zod'; - -const app = new App( - { name: "TicTacToe", version: "1.0.0" }, - { tools: { listChanged: true } } // Declare tool capability -); - -// Register a tool with schema validation -const moveTool = app.registerTool( - "tictactoe_move", - { - description: "Make a move in the tic-tac-toe game", - inputSchema: z.object({ - position: z.number().int().min(0).max(8), - player: z.enum(['X', 'O']) - }), - outputSchema: z.object({ - board: z.array(z.string()).length(9), - winner: z.enum(['X', 'O', 'draw', null]).nullable() - }), - annotations: { - readOnlyHint: false // This tool has side effects - } - }, - async (params) => { - // Validate and execute move - const newBoard = makeMove(params.position, params.player); - const winner = checkWinner(newBoard); - - return { - content: [{ - type: "text", - text: `Move made at position ${params.position}` - }], - structuredContent: { - board: newBoard, - winner - } - }; - } -); - -await app.connect(new PostMessageTransport(window.parent)); -``` - -**Registration Options:** - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `name` | string | Yes | Unique tool identifier | -| `description` | string | No | Human-readable description for agent | -| `inputSchema` | [Standard Schema](https://standardschema.dev/) | No | Validates arguments; serialized to JSON Schema for `tools/list` | -| `outputSchema` | [Standard Schema](https://standardschema.dev/) | No | Validates `structuredContent` | -| `annotations` | ToolAnnotations | No | MCP tool hints (e.g., `readOnlyHint`) | -| `_meta` | object | No | Custom metadata | - -Apps can also implement tool handling manually without the SDK: - -```javascript -app.oncalltool = async (params, extra) => { - if (params.name === "tictactoe_move") { - // Manual validation - if (typeof params.arguments?.position !== 'number') { - throw new Error("Invalid position"); - } - - // Execute tool - const newBoard = makeMove(params.arguments.position, params.arguments.player); - - return { - content: [{ type: "text", text: "Move made" }], - structuredContent: { board: newBoard } - }; - } - - throw new Error(`Unknown tool: ${params.name}`); -}; - -app.onlisttools = async () => { - return { - tools: [ - { - name: "tictactoe_move", - description: "Make a move in the game", - inputSchema: { - type: "object", - properties: { - position: { type: "number", minimum: 0, maximum: 8 }, - player: { type: "string", enum: ["X", "O"] } - }, - required: ["position", "player"] - } - } - ] - }; -}; -``` - -#### Tool Lifecycle - -Registered tools support dynamic lifecycle management: - -**Enable/Disable:** - -```typescript -const tool = app.registerTool("my_tool", config, callback); - -// Disable tool (hide from tools/list) -tool.disable(); - -// Re-enable tool -tool.enable(); -``` - -When a tool is disabled/enabled, the app automatically sends `notifications/tools/list_changed` (if the app declared `listChanged: true` capability). - -**Update:** - -```typescript -// Update tool description or schema -tool.update({ - description: "New description", - inputSchema: newSchema -}); -``` - -Updates also trigger `notifications/tools/list_changed`. - -**Remove:** - -```typescript -// Permanently remove tool -tool.remove(); -``` - -#### Long-Running App Tools - -App tools are bounded by the app's render lifecycle. For work that may outlive a single render, app `tools/call` MAY be task-augmented per [core MCP Tasks](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks); hosts proxy the `tasks/*` methods to the app exactly as they proxy `tools/call`. Because the iframe can be torn down at any time, apps SHOULD delegate long-lived work to the server (via `callServerTool()`) and return the server's task handle, so the host can continue polling the server after teardown. - -#### Schema Validation - -The SDK validates input and output against any [Standard Schema](https://standardschema.dev/)โ€“compatible library (Zod, ArkType, Valibot, โ€ฆ). Examples below use Zod: - -**Input Validation:** - -```typescript -app.registerTool( - "search", - { - inputSchema: z.object({ - query: z.string().min(1).max(100), - limit: z.number().int().positive().default(10) - }) - }, - async (params) => { - // params.query is guaranteed to be a string (1-100 chars) - // params.limit is guaranteed to be a positive integer (default 10) - return performSearch(params.query, params.limit); - } -); -``` - -If the host sends invalid arguments, the tool automatically returns an error before the callback is invoked. - -**Output Validation:** - -```typescript -app.registerTool( - "get_status", - { - outputSchema: z.object({ - status: z.enum(['ready', 'busy', 'error']), - timestamp: z.string().datetime() - }) - }, - async () => { - return { - content: [{ type: "text", text: "Status retrieved" }], - structuredContent: { - status: 'ready', - timestamp: new Date().toISOString() - } - }; - } -); -``` - -If the callback returns data that doesn't match `outputSchema`, the tool returns an error. - -#### Complete Example: Introspectable Tic-Tac-Toe - -This example demonstrates how apps expose semantic interfaces through tools: - -```typescript -import { App } from '@modelcontextprotocol/ext-apps'; -import { z } from 'zod'; - -// Game state -let board: Array<'X' | 'O' | null> = Array(9).fill(null); -let currentPlayer: 'X' | 'O' = 'X'; -let moveHistory: number[] = []; - -const app = new App( - { name: "TicTacToe", version: "1.0.0" }, - { tools: { listChanged: true } } -); - -// Agent can query semantic state directly -app.registerTool( - "get_board_state", - { - description: "Get current game state including board, current player, and winner", - outputSchema: z.object({ - board: z.array(z.enum(['X', 'O', null])).length(9), - currentPlayer: z.enum(['X', 'O']), - winner: z.enum(['X', 'O', 'draw', null]).nullable(), - moveHistory: z.array(z.number()) - }) - }, - async () => { - return { - content: [{ - type: "text", - text: `Board: ${board.map(c => c || '-').join('')}, Player: ${currentPlayer}` - }], - structuredContent: { - board, - currentPlayer, - winner: checkWinner(board), - moveHistory - } - }; - } -); - -// Agent can execute moves -app.registerTool( - "make_move", - { - description: "Place a piece at the specified position", - inputSchema: z.object({ - position: z.number().int().min(0).max(8) - }), - annotations: { readOnlyHint: false } - }, - async ({ position }) => { - if (board[position] !== null) { - return { - content: [{ type: "text", text: "Position already taken" }], - isError: true - }; - } - - board[position] = currentPlayer; - moveHistory.push(position); - const winner = checkWinner(board); - currentPlayer = currentPlayer === 'X' ? 'O' : 'X'; - - return { - content: [{ - type: "text", - text: `Player ${board[position]} moved to position ${position}` + - (winner ? `. ${winner} wins!` : '') - }], - structuredContent: { - board, - currentPlayer, - winner, - moveHistory - } - }; - } -); - -// Agent can reset game -app.registerTool( - "reset_game", - { - description: "Reset the game board to initial state", - annotations: { readOnlyHint: false } - }, - async () => { - board = Array(9).fill(null); - currentPlayer = 'X'; - moveHistory = []; - - return { - content: [{ type: "text", text: "Game reset" }], - structuredContent: { board, currentPlayer, moveHistory } - }; - } -); - -await app.connect(new PostMessageTransport(window.parent)); - -function checkWinner(board: Array<'X' | 'O' | null>): 'X' | 'O' | 'draw' | null { - const lines = [ - [0, 1, 2], [3, 4, 5], [6, 7, 8], // rows - [0, 3, 6], [1, 4, 7], [2, 5, 8], // columns - [0, 4, 8], [2, 4, 6] // diagonals - ]; - - for (const [a, b, c] of lines) { - if (board[a] && board[a] === board[b] && board[a] === board[c]) { - return board[a]; - } - } - - return board.every(cell => cell !== null) ? 'draw' : null; -} -``` - -**Agent Interaction:** - -```typescript -// 1. Discover available operations -const { tools } = await bridge.sendListTools({}); -// โ†’ ["get_board_state", "make_move", "reset_game"] - -// 2. Query semantic state (not visual/DOM) -const state = await bridge.sendCallTool({ - name: "get_board_state", - arguments: {} -}); -// โ†’ { board: [null, null, null, ...], currentPlayer: 'X', winner: null } - -// 3. Execute actions based on semantic understanding -if (state.structuredContent.board[4] === null) { - await bridge.sendCallTool({ - name: "make_move", - arguments: { position: 4 } - }); -} - -// 4. Query updated state -const newState = await bridge.sendCallTool({ - name: "get_board_state", - arguments: {} -}); -// โ†’ { board: [null, null, null, null, 'X', null, ...], currentPlayer: 'O', ... } -``` - -The agent interacts with the app through semantic operations rather than visual interpretation. - -#### Tool Flow Directions - -**Existing Flow (unchanged): App โ†’ Host โ†’ Server** - -Apps call server tools (proxied by host): - -```typescript -// App calls server tool -const result = await app.callServerTool("get_weather", { location: "NYC" }); -``` - -Requires host `serverTools` capability. - -**New Flow: Host/Agent โ†’ App** - -Host/Agent calls app-registered tools: - -```typescript -// Host calls app tool -const result = await bridge.sendCallTool({ - name: "tictactoe_move", - arguments: { position: 4 } -}); -``` - -Requires app `tools` capability. - -**Key Distinction:** - -| Aspect | Server Tools | App Tools | -|--------|-------------|-----------| -| **Lifetime** | Persistent (server process) | Ephemeral (while app loaded) | -| **Source** | MCP Server | App JavaScript | -| **Trust** | Trusted | Sandboxed (untrusted) | -| **Discovery** | Server `tools/list` | App `tools/list` (when app declares capability) | -| **When Available** | Always | Only while app is loaded | - -#### Use Cases - -**Introspection:** Agent queries app state semantically โ€” the app's DOM is not exposed to the host or model - -**Voice mode:** Agent drives app interactions programmatically based on voice commands - -**Accessibility:** Structured state and operations more accessible than visual rendering - -**Complex workflows:** Agent discovers available operations and coordinates multi-step interactions - -**Stateful apps:** Apps expose operations (move, reset, query) rather than pushing state updates via messages - -#### Security Implications - -App tools run in **sandboxed iframes** (untrusted). See Security Implications section for detailed mitigations. - -Key considerations: -- App tools could provide misleading descriptions -- Tool namespacing needed to avoid conflicts with server tools -- Resource limits (max tools, execution timeouts) -- Audit trail for app tool invocations -- User confirmation for tools with side effects - -#### Relation to WebMCP - -This feature is inspired by [WebMCP](https://github.com/webmachinelearning/webmcp) (W3C incubation), which proposes allowing web pages to register JavaScript functions as tools via `navigator.modelContext.registerTool()`. - -Key differences: -- **WebMCP**: General web pages, browser API, manifest-based discovery -- **This spec**: MCP Apps, standard MCP messages, capability-based negotiation - -Similar to WebMCP but without turning the App (embedded page) into an MCP server - apps register tools within the App/Host architecture. - -See [ext-apps#35](https://github.com/modelcontextprotocol/ext-apps/issues/35) for discussion. - ### Client\<\>Server Capability Negotiation Clients and servers negotiate MCP Apps support through the standard MCP extensions capability mechanism (defined in SEP-1724). @@ -2215,13 +1449,13 @@ Future versions may add additional settings: #### Server Behavior -Servers SHOULD check client capabilities before registering UI-enabled tools. The SDK provides the `getUiCapability` helper for this: +Servers SHOULD check client (host would-be) capabilities before registering UI-enabled tools: ```typescript -import { getUiCapability, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; +const hasUISupport = + clientCapabilities?.extensions?.["io.modelcontextprotocol/ui"]?.mimeTypes?.includes("text/html;profile=mcp-app"); -const uiCap = getUiCapability(clientCapabilities); -if (uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) { +if (hasUISupport) { // Register tools with UI templates server.registerTool("get_weather", { description: "Get weather with interactive dashboard", @@ -2246,109 +1480,20 @@ if (uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) { - Tools MUST return meaningful content array even when UI is available - Servers MAY register different tool variants based on host capabilities -#### App (Guest UI) Capabilities - -Apps advertise their capabilities in the `ui/initialize` request to the host. When an app supports tool registration, it includes the `tools` capability: - -```json -{ - "method": "ui/initialize", - "params": { - "appInfo": { - "name": "TicTacToe", - "version": "1.0.0" - }, - "appCapabilities": { - "tools": { - "listChanged": true - } - } - } -} -``` - -The host responds with its own capabilities, including support for proxying server tools: - -```json -{ - "result": { - "hostInfo": { - "name": "claude-desktop", - "version": "1.0.0" - }, - "hostCapabilities": { - "serverTools": { - "listChanged": true - }, - "openLinks": {}, - "logging": {} - } - } -} -``` - -**App Capability: `tools`** - -When present, the app can register tools that the host and agent can call. - -- `listChanged` (boolean, optional): If `true`, the app will send `notifications/tools/list_changed` when tools are added, removed, or modified - -**Host Capability: `serverTools`** - -When present, the host can proxy calls from the app to MCP server tools. - -- `listChanged` (boolean, optional): If `true`, the host will send `notifications/tools/list_changed` when server tools change - -These capabilities are independent - an app can have one, both, or neither. - -**TypeScript Types:** - -```typescript -interface McpUiAppCapabilities { - tools?: { - listChanged?: boolean; - }; -} - -interface McpUiHostCapabilities { - serverTools?: { - listChanged?: boolean; - }; - openLinks?: {}; - logging?: {}; -} -``` - ### Extensibility -This specification defines the Minimum Viable Product (MVP) for MCP Apps. - -**Included in MVP:** - -- **App-Provided Tools:** Apps can register tools via `app.registerTool()` that agents can call - - Bidirectional tool flow (Apps consume server tools AND provide app tools) - - Full lifecycle management (enable/disable/update/remove) - - Schema validation via [Standard Schema](https://standardschema.dev/) (Zod, ArkType, Valibot, โ€ฆ) - - Tool list change notifications +This specification defines the Minimum Viable Product (MVP) for MCP Apps. Future extensions may include: **Content Types (deferred from MVP):** - `externalUrl`: Embed external web applications (e.g., `text/uri-list`) -**Advanced Tool Features (future extensions):** - -- Tool namespacing standards and conventions -- Standardized permission model specifications -- Tool categories/tags for organization -- Cross-app tool composition -- Tool marketplace/discovery mechanisms - -**Other Advanced Features (see Future Considerations):** +**Advanced Features (see Future Considerations):** - Support multiple UI resources in a tool response - State persistence and restoration - Custom sandbox policies per resource -- View-to-View communication +- Widget-to-widget communication - Screenshot/preview generation APIs ## Rationale @@ -2381,13 +1526,13 @@ This proposal synthesizes feedback from the UI CWG and MCP-UI community, host im - Reuses existing MCP infrastructure (type definitions, error handling, timeouts) - UI developers can use standard MCP SDK (`@modelcontextprotocol/sdk`) or alternatives -- Automatic compatibility with core MCP features (Tasks for long-running calls, sampling, etc.) +- Automatic compatibility with future MCP features (long-running tools, sampling, etc.) - Better auditability through structured JSON-RPC messages - Reduces maintenance burden (no parallel protocol to evolve) **Alternatives considered:** -- **Custom message protocol:** Current MCP-UI approach with message types like tool, prompt, etc. These message types can be translated to a subset of the proposed JSON-RPC messages. +- **Custom message protocol:** Current MCP-UI approach with message types like tool, intent, prompt, etc. These message types can be translated to a subset of the proposed JSON-RPC messages. - **Global API object:** Rejected because it requires host-specific injection and doesn't work with external iframe sources. Syntactic sugar may still be added on the server/UI side. #### 3. Support Raw HTML Content Type @@ -2414,7 +1559,7 @@ This proposal synthesizes feedback from the UI CWG and MCP-UI community, host im **Rationale:** - CSS variables are universal, framework-agnostic, and require no runtime -- Views apply styles via `var(--name)` with fallbacks for graceful degradation +- Apps apply styles via `var(--name)` with fallbacks for graceful degradation - Limited variable set (colors, typography, borders) ensures hosts can realistically provide all values - Spacing intentionally excludedโ€”layouts break when spacing varies from original design - No UI component libraryโ€”no single library works across all host environments @@ -2443,37 +1588,6 @@ This proposal synthesizes feedback from the UI CWG and MCP-UI community, host im - **Boolean `private` flag:** Simpler but less flexible; doesn't express model-only tools. - **Flat `ui/visibility` key:** Rejected in favor of nested structure for consistency with future `_meta.ui` fields. -#### 6. App Tool Registration Support - -**Decision:** Enable Apps to register tools using standard MCP `tools/call` and `tools/list` messages, making tools flow bidirectionally between Apps and Hosts. - -**Rationale:** - -- **Semantic introspection:** Apps expose state and operations in a structured, machine-readable form. Hosts have no guaranteed DOM access into an app (which may itself nest cross-origin frames via `frameDomains`), so a tool surface is the only reliable introspection path -- **Protocol reuse:** Reuses existing MCP tool infrastructure (`tools/call`, `tools/list`, `notifications/tools/list_changed`) instead of inventing new message types -- **WebMCP alignment:** Brings WebMCP's vision of "JavaScript functions as tools" to MCP Apps while staying MCP-native -- **Agent-driven interaction:** Agents can pull app state and issue commands on demand, complementing push-style [`ui/update-model-context`](#uiupdate-model-context) -- **Bidirectional symmetry:** Apps act as both MCP clients (calling server tools) and MCP servers (providing app tools), creating clean architectural symmetry -- **Use case coverage:** Enables interactive games, stateful forms, complex workflows, and reusable widgets - -**Alternatives considered:** - -- **Custom app-action API:** Rejected because it would duplicate MCP's existing tool infrastructure and create parallel protocol semantics. Using standard `tools/call` means automatic compatibility with future MCP features and better ecosystem integration. -- **Server-side proxy tools:** Apps could expose operations by having the server register proxy tools that communicate back to the app. Rejected because it doesn't leverage the app's JavaScript execution environment, adds unnecessary round-trips, and couples app functionality to server implementation. -- **Resources instead of tools:** Apps could expose state via `resources/read` rather than tools. Rejected because resources have wrong semantics (passive data retrieval vs. active operations), don't support parameters well, and don't convey operational intent. - -**Security implications:** - -Apps are forward-deployed emanations of server tools, running in the client context. Hosts should consider how to handle tool call approval: - -- Per-app-instance approval (confirm each time a specific app instance calls a tool) -- Per-server approval (approve all apps from a trusted server) -- Per-tool approval (approve based on tool semantics and annotations) -- Clear attribution showing which app instance is calling a tool -- Audit trails for app tool calls - -See [Security Implications: App-Provided Tools Security](#5-app-provided-tools-security) for detailed considerations. - ### Backward Compatibility The proposal builds on the existing core protocol. There are no incompatibilities. @@ -2493,28 +1607,28 @@ Hosting interactive UI content from potentially untrusted MCP servers requires c Attackers may use the embedded UI in different scenarios. For example: - Malicious server delivers harmful HTML content -- Compromised View attempts to escape sandbox -- View attempts unauthorized tool execution -- View exfiltrates sensitive host data -- View performs phishing or social engineering +- Compromised UI attempts to escape sandbox +- UI attempts unauthorized tool execution +- UI exfiltrates sensitive host data +- UI performs phishing or social engineering ### Mitigations #### 1. Iframe Sandboxing -All View content MUST be rendered in sandboxed iframes with restricted permissions. +All UI content MUST be rendered in sandboxed iframes with restricted permissions. -The sandbox limits the View from accessing the host or manipulating it. All communication with the host is done via `postMessage`, where the host is in control. +The sandbox limits the UI from accessing the host or manipulating it. All communication with the host is done via `postMessage`, where the host is in control. #### 2. Auditable Communication -All View-to-host communication goes through auditable MCP JSON-RPC messages. +All UI-to-host communication goes through auditable MCP JSON-RPC messages. **Host behavior:** - Validate all incoming messages from UI iframes - Reject malformed message types -- Log View-initiated RPC calls for security review +- Log UI-initiated RPC calls for security review #### 3. Predeclared Resource Review @@ -2534,10 +1648,8 @@ Hosts MUST enforce Content Security Policies based on resource metadata. **CSP Construction from Metadata:** ```typescript -// Prefer content-level _meta.ui (resources/read), fall back to listing-level (resources/list) -const uiMeta = resource._meta?.ui ?? listingResource._meta?.ui; -const csp = uiMeta?.csp; -const permissions = uiMeta?.permissions; +const csp = resource._meta?.ui?.csp; // `resource` is extracted from the `contents` of the `resources/read` result +const permissions = resource._meta?.ui?.permissions; const cspValue = ` default-src 'none'; @@ -2566,87 +1678,10 @@ const allowAttribute = allowList.join(' '); - Host SHOULD warn users when UI requires external domain access - Host MAY implement global domain allowlists/blocklists -#### 5. App-Provided Tools Security - -Apps can register their own tools that agents can call. Apps are forward-deployed emanations of server tools, running in the client context. Hosts need to decide how to handle approval for app tool calls. - -**Approval Considerations:** - -App-provided tools introduce additional approval considerations: - -- **Tool description accuracy:** Apps may describe tools in ways that don't fully capture side effects -- **Namespace conflicts:** Apps could register tools with names conflicting with server tools -- **Resource consumption:** Apps could register many tools or implement slow callbacks -- **Data validation:** Tool results should match declared schemas -- **Semantic clarity:** Tool operations should be clear from their descriptions - -**Approval Granularity:** - -Hosts have discretion in how they handle app tool call approval: - -1. **Per-app-instance approval:** Confirm each time a specific app instance's tool is called -2. **Per-server approval:** Trust all apps from servers the user has approved -3. **Per-tool approval:** Approve based on tool annotations (e.g., `readOnlyHint`) -4. **Hybrid approaches:** Combine strategies (e.g., auto-approve read-only tools from trusted servers) - -**Host Protections:** - -Hosts SHOULD implement the following protections for app-provided tools: - -1. **Clear Attribution:** - - Display tool source in agent's tool list (e.g., "Tool from TicTacToe App") - - Visually distinguish app tools from server tools in UI - - Show app name and version in tool call confirmations - -2. **User Confirmation:** - - Require explicit user approval for tools with `readOnlyHint: false` - - Consider auto-approving tools with `readOnlyHint: true` after review - - Implement per-app permission settings (always allow, always deny, ask) - -3. **Namespace Management:** - - Recommend or enforce tool name prefixes (e.g., `app:move`, `tictactoe:move`) - - Prevent apps from registering tool names that conflict with server tools - - Document namespace conventions for app developers - -4. **Resource Limits:** - - Limit maximum number of tools per app (recommended: 50) - - Enforce execution timeouts for tool callbacks (recommended: 30 seconds) - - Limit tool result sizes (recommended: 10 MB) - - Throttle `tools/list_changed` notifications to prevent spam - -5. **Audit Trail:** - - Log all app tool registrations with timestamps - - Log all app tool calls with arguments and results - - Provide audit interface for users to review app tool activity - -6. **Result Validation:** - - Validate tool results match declared schemas - - Sanitize result content before displaying to user or agent - - Reject results that appear malicious (e.g., phishing content) - -**Permission Model:** - -Hosts MAY implement different permission levels based on tool annotations: - -| Annotation | Recommended Permission | Example | -|---------------------|------------------------|-------------------| -| `readOnlyHint: true`| Auto-approve (with caution) | `get_board_state()` | -| `readOnlyHint: false` | User confirmation required | `make_move()` | -| No annotation | User confirmation required (safe default) | Any tool | - -**App Tool Lifecycle:** - -App tools MUST be tied to the app's lifecycle: - -- Tools become available once the app advertises the `tools` capability in `ui/initialize` and the host issues `tools/list`; subsequent changes are signaled via `notifications/tools/list_changed` -- Tools automatically disappear when app iframe is torn down -- Hosts MUST NOT persist app tool registrations across sessions -- Calling a tool from a closed app MUST return an error - ### Other risks - **Social engineering:** UI can still display misleading content. Hosts should clearly indicate sandboxed UI boundaries. -- **Resource consumption:** Malicious View can consume CPU/memory. Hosts should implement resource limits. +- **Resource consumption:** Malicious UI can consume CPU/memory. Hosts should implement resource limits. ## Reservations in MCP diff --git a/src/app-bridge.examples.ts b/src/app-bridge.examples.ts deleted file mode 100644 index 47ad623..0000000 --- a/src/app-bridge.examples.ts +++ /dev/null @@ -1,515 +0,0 @@ -/** - * Type-checked examples for {@link AppBridge `AppBridge`}. - * - * These examples are included in the API documentation via `@includeCode` tags. - * Each function's region markers define the code snippet that appears in the docs. - * - * @module - */ - -import { Client, type Transport } from "@modelcontextprotocol/client"; -import type { - CreateMessageRequest, - CreateMessageResult, -} from "@modelcontextprotocol/server"; -import { AppBridge, PostMessageTransport } from "./app-bridge.js"; -import type { McpUiDisplayMode } from "./types.js"; - -/** - * Example: Basic usage of the AppBridge class with PostMessageTransport. - */ -async function AppBridge_basicUsage(serverTransport: Transport) { - //#region AppBridge_basicUsage - // Create MCP client for the server - const client = new Client({ - name: "MyHost", - version: "1.0.0", - }); - await client.connect(serverTransport); - - // Create bridge for the View - const bridge = new AppBridge( - client, - { name: "MyHost", version: "1.0.0" }, - { openLinks: {}, serverTools: {}, logging: {} }, - ); - - // Set up iframe and connect - const iframe = document.getElementById("app") as HTMLIFrameElement; - const transport = new PostMessageTransport( - iframe.contentWindow!, - iframe.contentWindow!, - ); - - bridge.oninitialized = () => { - console.log("View initialized"); - // Now safe to send tool input - bridge.sendToolInput({ arguments: { location: "NYC" } }); - }; - - await bridge.connect(transport); - //#endregion AppBridge_basicUsage -} - -/** - * Example: Creating an AppBridge with an MCP client for automatic forwarding. - */ -function AppBridge_constructor_withMcpClient(mcpClient: Client) { - //#region AppBridge_constructor_withMcpClient - const bridge = new AppBridge( - mcpClient, - { name: "MyHost", version: "1.0.0" }, - { openLinks: {}, serverTools: {}, logging: {} }, - ); - //#endregion AppBridge_constructor_withMcpClient -} - -/** - * Example: Creating an AppBridge without an MCP client, using manual handlers. - */ -function AppBridge_constructor_withoutMcpClient() { - //#region AppBridge_constructor_withoutMcpClient - const bridge = new AppBridge( - null, - { name: "MyHost", version: "1.0.0" }, - { openLinks: {}, serverTools: {}, logging: {} }, - ); - bridge.oncalltool = async (params, extra) => { - // Handle tool calls manually - return { content: [] }; - }; - //#endregion AppBridge_constructor_withoutMcpClient -} - -/** - * Example: Check View capabilities after initialization. - */ -function AppBridge_getAppCapabilities_checkAfterInit(bridge: AppBridge) { - //#region AppBridge_getAppCapabilities_checkAfterInit - bridge.oninitialized = () => { - const caps = bridge.getAppCapabilities(); - if (caps?.tools) { - console.log("View provides tools"); - } - }; - //#endregion AppBridge_getAppCapabilities_checkAfterInit -} - -/** - * Example: Log View information after initialization. - */ -function AppBridge_getAppVersion_logAfterInit(bridge: AppBridge) { - //#region AppBridge_getAppVersion_logAfterInit - bridge.oninitialized = () => { - const appInfo = bridge.getAppVersion(); - if (appInfo) { - console.log(`View: ${appInfo.name} v${appInfo.version}`); - } - }; - //#endregion AppBridge_getAppVersion_logAfterInit -} - -/** - * Example: Handle View initialization and send tool input. - */ -function AppBridge_oninitialized_sendToolInput( - bridge: AppBridge, - toolArgs: Record<string, unknown>, -) { - //#region AppBridge_oninitialized_sendToolInput - bridge.oninitialized = () => { - console.log("View ready"); - bridge.sendToolInput({ arguments: toolArgs }); - }; - //#endregion AppBridge_oninitialized_sendToolInput -} - -/** - * Example: Handle message requests from the View. - */ -function AppBridge_onmessage_logMessage(bridge: AppBridge) { - //#region AppBridge_onmessage_logMessage - bridge.onmessage = async ({ role, content }, extra) => { - try { - await chatManager.addMessage({ role, content, source: "app" }); - return {}; // Success - } catch (error) { - console.error("Failed to add message:", error); - return { isError: true }; - } - }; - //#endregion AppBridge_onmessage_logMessage -} - -// Stub for example code - represents a hypothetical chat manager -declare const chatManager: { - addMessage(message: { - role: string; - content: unknown; - source: string; - }): Promise<void>; -}; - -// Stub for example code - represents a hypothetical URL validator -declare function isAllowedDomain(url: string): boolean; - -// Stub for example code - represents a hypothetical dialog API -declare function showDialog(options: { - message: string; - buttons: string[]; -}): Promise<boolean>; - -// Stub for example code - represents a hypothetical model context manager -declare const modelContextManager: { - update(context: { content?: unknown; structuredContent?: unknown }): void; -}; - -/** - * Example: Handle external link requests from the View. - */ -function AppBridge_onopenlink_handleRequest(bridge: AppBridge) { - //#region AppBridge_onopenlink_handleRequest - bridge.onopenlink = async ({ url }, extra) => { - if (!isAllowedDomain(url)) { - console.warn("Blocked external link:", url); - return { isError: true }; - } - - const confirmed = await showDialog({ - message: `Open external link?\n${url}`, - buttons: ["Open", "Cancel"], - }); - - if (confirmed) { - window.open(url, "_blank", "noopener,noreferrer"); - return {}; - } - - return { isError: true }; - }; - //#endregion AppBridge_onopenlink_handleRequest -} - -/** - * Example: Store model context updates from the View. - */ -function AppBridge_onupdatemodelcontext_storeContext(bridge: AppBridge) { - //#region AppBridge_onupdatemodelcontext_storeContext - bridge.onupdatemodelcontext = async ( - { content, structuredContent }, - extra, - ) => { - // Store the context snapshot for inclusion in the next model request - modelContextManager.update({ content, structuredContent }); - return {}; - }; - //#endregion AppBridge_onupdatemodelcontext_storeContext -} - -/** - * Example: Forward tool calls to the MCP server. - */ -function AppBridge_oncalltool_forwardToServer( - bridge: AppBridge, - mcpClient: Client, -) { - //#region AppBridge_oncalltool_forwardToServer - bridge.oncalltool = async (params, extra) => { - return mcpClient.request( - { method: "tools/call", params }, - { signal: extra.mcpReq.signal }, - ); - }; - //#endregion AppBridge_oncalltool_forwardToServer -} - -/** - * Example: Forward sampling requests to your LLM provider. - */ -function AppBridge_oncreatesamplingmessage_forwardToLlm( - bridge: AppBridge, - myLlmProvider: { - complete: ( - params: CreateMessageRequest["params"], - opts: { signal: AbortSignal }, - ) => Promise<CreateMessageResult>; - }, -) { - //#region AppBridge_oncreatesamplingmessage_forwardToLlm - bridge.oncreatesamplingmessage = async (params, extra) => { - // Apply rate limiting, user approval, cost controls here - return await myLlmProvider.complete(params, { - signal: extra.mcpReq.signal, - }); - }; - //#endregion AppBridge_oncreatesamplingmessage_forwardToLlm -} - -/** - * Example: Forward list resources requests to the MCP server. - */ -function AppBridge_onlistresources_returnResources( - bridge: AppBridge, - mcpClient: Client, -) { - //#region AppBridge_onlistresources_returnResources - bridge.onlistresources = async (params, extra) => { - return mcpClient.request( - { method: "resources/list", params }, - { signal: extra.mcpReq.signal }, - ); - }; - //#endregion AppBridge_onlistresources_returnResources -} - -/** - * Example: Forward read resource requests to the MCP server. - */ -function AppBridge_onreadresource_returnResource( - bridge: AppBridge, - mcpClient: Client, -) { - //#region AppBridge_onreadresource_returnResource - bridge.onreadresource = async (params, extra) => { - return mcpClient.request( - { method: "resources/read", params }, - { signal: extra.mcpReq.signal }, - ); - }; - //#endregion AppBridge_onreadresource_returnResource -} - -/** - * Example: Forward list prompts requests to the MCP server. - */ -function AppBridge_onlistprompts_returnPrompts( - bridge: AppBridge, - mcpClient: Client, -) { - //#region AppBridge_onlistprompts_returnPrompts - bridge.onlistprompts = async (params, extra) => { - return mcpClient.request( - { method: "prompts/list", params }, - { signal: extra.mcpReq.signal }, - ); - }; - //#endregion AppBridge_onlistprompts_returnPrompts -} - -/** - * Example: Handle ping requests from the View. - */ -function AppBridge_onping_handleRequest(bridge: AppBridge) { - //#region AppBridge_onping_handleRequest - bridge.onping = (params, extra) => { - console.log("Received ping from view"); - }; - //#endregion AppBridge_onping_handleRequest -} - -/** - * Example: Handle size change notifications from the View. - */ -function AppBridge_onsizechange_handleResize( - bridge: AppBridge, - iframe: HTMLIFrameElement, -) { - //#region AppBridge_onsizechange_handleResize - bridge.onsizechange = ({ width, height }) => { - if (width != null) { - iframe.style.width = `${width}px`; - } - if (height != null) { - iframe.style.height = `${height}px`; - } - }; - //#endregion AppBridge_onsizechange_handleResize -} - -/** - * Example: Handle display mode requests from the View. - */ -function AppBridge_onrequestdisplaymode_handleRequest( - bridge: AppBridge, - currentDisplayMode: McpUiDisplayMode, - availableDisplayModes: McpUiDisplayMode[], -) { - //#region AppBridge_onrequestdisplaymode_handleRequest - bridge.onrequestdisplaymode = async ({ mode }, extra) => { - if (availableDisplayModes.includes(mode)) { - currentDisplayMode = mode; - } - return { mode: currentDisplayMode }; - }; - //#endregion AppBridge_onrequestdisplaymode_handleRequest -} - -/** - * Example: Handle logging messages from the View. - */ -function AppBridge_onloggingmessage_handleLog(bridge: AppBridge) { - //#region AppBridge_onloggingmessage_handleLog - bridge.onloggingmessage = ({ level, logger, data }) => { - console[level === "error" ? "error" : "log"]( - `[${logger ?? "View"}] ${level.toUpperCase()}:`, - data, - ); - }; - //#endregion AppBridge_onloggingmessage_handleLog -} - -/** - * Example: Gracefully tear down the View before unmounting. - */ -async function AppBridge_teardownResource_gracefulShutdown( - bridge: AppBridge, - iframe: HTMLIFrameElement, -) { - //#region AppBridge_teardownResource_gracefulShutdown - try { - await bridge.teardownResource({}); - // View is ready, safe to unmount iframe - iframe.remove(); - } catch (error) { - console.error("Teardown failed:", error); - } - //#endregion AppBridge_teardownResource_gracefulShutdown -} - -/** - * Example: Update theme when user toggles dark mode. - */ -function AppBridge_setHostContext_updateTheme(bridge: AppBridge) { - //#region AppBridge_setHostContext_updateTheme - bridge.setHostContext({ theme: "dark" }); - //#endregion AppBridge_setHostContext_updateTheme -} - -/** - * Example: Update multiple context fields at once. - */ -function AppBridge_setHostContext_updateMultiple(bridge: AppBridge) { - //#region AppBridge_setHostContext_updateMultiple - bridge.setHostContext({ - theme: "dark", - containerDimensions: { maxHeight: 600, width: 800 }, - }); - //#endregion AppBridge_setHostContext_updateMultiple -} - -/** - * Example: Send tool input after initialization. - */ -function AppBridge_sendToolInput_afterInit(bridge: AppBridge) { - //#region AppBridge_sendToolInput_afterInit - bridge.oninitialized = () => { - bridge.sendToolInput({ - arguments: { location: "New York", units: "metric" }, - }); - }; - //#endregion AppBridge_sendToolInput_afterInit -} - -/** - * Example: Stream partial arguments as they arrive. - */ -function AppBridge_sendToolInputPartial_streaming(bridge: AppBridge) { - //#region AppBridge_sendToolInputPartial_streaming - // As streaming progresses... - bridge.sendToolInputPartial({ arguments: { loc: "N" } }); - bridge.sendToolInputPartial({ arguments: { location: "New" } }); - bridge.sendToolInputPartial({ arguments: { location: "New York" } }); - - // When complete, send final input - bridge.sendToolInput({ - arguments: { location: "New York", units: "metric" }, - }); - //#endregion AppBridge_sendToolInputPartial_streaming -} - -/** - * Example: Send tool result after execution. - */ -async function AppBridge_sendToolResult_afterExecution( - bridge: AppBridge, - mcpClient: Client, - args: Record<string, unknown>, -) { - //#region AppBridge_sendToolResult_afterExecution - const result = await mcpClient.request({ - method: "tools/call", - params: { name: "get_weather", arguments: args }, - }); - bridge.sendToolResult(result); - //#endregion AppBridge_sendToolResult_afterExecution -} - -/** - * Example: User-initiated cancellation. - */ -function AppBridge_sendToolCancelled_userInitiated(bridge: AppBridge) { - //#region AppBridge_sendToolCancelled_userInitiated - // User clicked "Cancel" button - bridge.sendToolCancelled({ reason: "User cancelled the operation" }); - //#endregion AppBridge_sendToolCancelled_userInitiated -} - -/** - * Example: System-level cancellation. - */ -function AppBridge_sendToolCancelled_systemLevel(bridge: AppBridge) { - //#region AppBridge_sendToolCancelled_systemLevel - // Sampling error or timeout - bridge.sendToolCancelled({ reason: "Request timeout after 30 seconds" }); - - // Classifier intervention - bridge.sendToolCancelled({ reason: "Content policy violation detected" }); - //#endregion AppBridge_sendToolCancelled_systemLevel -} - -/** - * Example: Connect with MCP client for automatic forwarding. - */ -async function AppBridge_connect_withMcpClient( - iframe: HTMLIFrameElement, - mcpClient: Client, - hostInfo: { name: string; version: string }, - capabilities: { openLinks: {}; serverTools: {}; logging: {} }, - toolArgs: Record<string, unknown>, -) { - //#region AppBridge_connect_withMcpClient - const bridge = new AppBridge(mcpClient, hostInfo, capabilities); - const transport = new PostMessageTransport( - iframe.contentWindow!, - iframe.contentWindow!, - ); - - bridge.oninitialized = () => { - console.log("View ready"); - bridge.sendToolInput({ arguments: toolArgs }); - }; - - await bridge.connect(transport); - //#endregion AppBridge_connect_withMcpClient -} - -/** - * Example: Connect without MCP client using manual handlers. - */ -async function AppBridge_connect_withoutMcpClient( - hostInfo: { name: string; version: string }, - capabilities: { openLinks: {}; serverTools: {}; logging: {} }, - transport: Transport, -) { - //#region AppBridge_connect_withoutMcpClient - const bridge = new AppBridge(null, hostInfo, capabilities); - - // Register handlers manually - bridge.oncalltool = async (params, extra) => { - // Custom tool call handling - return { content: [] }; - }; - - await bridge.connect(transport); - //#endregion AppBridge_connect_withoutMcpClient -} diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index a87b0e7..66d5f83 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -1,19 +1,24 @@ -import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test"; -import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import { Server, type ServerCapabilities } from "@modelcontextprotocol/server"; -import { z } from "zod/v4"; +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { ServerCapabilities } from "@modelcontextprotocol/sdk/types.js"; +import { + EmptyResultSchema, + ListPromptsResultSchema, + ListResourcesResultSchema, + ListResourceTemplatesResultSchema, + PromptListChangedNotificationSchema, + ReadResourceResultSchema, + ResourceListChangedNotificationSchema, + ToolListChangedNotificationSchema, +} from "@modelcontextprotocol/sdk/types.js"; -import { App } from "./app.js"; -import { LATEST_PROTOCOL_VERSION } from "./types.js"; +import { App } from "./app"; import { AppBridge, - buildAllowAttribute, getToolUiResourceUri, - isToolVisibilityModelOnly, - isToolVisibilityAppOnly, - McpUiOpenLinkResultSchema, type McpUiHostCapabilities, -} from "./app-bridge.js"; +} from "./app-bridge"; /** Wait for pending microtasks to complete */ const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); @@ -24,27 +29,17 @@ const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); */ function createMockClient( serverCapabilities: ServerCapabilities = {}, -): Pick< - Client, - | "getServerCapabilities" - | "request" - | "notification" - | "setNotificationHandler" -> { +): Pick<Client, "getServerCapabilities" | "request" | "notification"> { return { getServerCapabilities: () => serverCapabilities, request: async () => ({}) as never, notification: async () => {}, - setNotificationHandler: () => {}, }; } const testHostInfo = { name: "TestHost", version: "1.0.0" }; const testAppInfo = { name: "TestApp", version: "1.0.0" }; const testHostCapabilities: McpUiHostCapabilities = { - experimental: { - "com.example/host-extension": { version: 1 }, - }, openLinks: {}, serverTools: {}, logging: {}, @@ -85,53 +80,6 @@ describe("App <-> AppBridge integration", () => { expect(initializedFired).toBe(true); }); - it("sends only the Apps handshake on the wire and gates the Apps-ready callback", async () => { - const methods: string[] = []; - let releaseAppsInitialized!: () => void; - let reachedAppsInitialized!: () => void; - const appsInitializedGate = new Promise<void>((resolve) => { - releaseAppsInitialized = resolve; - }); - const reachedGate = new Promise<void>((resolve) => { - reachedAppsInitialized = resolve; - }); - const originalSend = appTransport.send.bind(appTransport); - appTransport.send = async (message, options) => { - if ("method" in message) methods.push(message.method); - if ( - "method" in message && - message.method === "ui/notifications/initialized" - ) { - reachedAppsInitialized(); - await appsInitializedGate; - } - return originalSend(message, options); - }; - - let singularCalls = 0; - let listenerCalls = 0; - bridge.oninitialized = () => singularCalls++; - bridge.addEventListener("initialized", () => listenerCalls++); - - await bridge.connect(bridgeTransport); - const connecting = app.connect(appTransport); - await reachedGate; - - expect(methods).toEqual([ - "ui/initialize", - "ui/notifications/initialized", - ]); - expect(bridge.getAppVersion()).toEqual(testAppInfo); - expect(singularCalls).toBe(0); - expect(listenerCalls).toBe(0); - - releaseAppsInitialized(); - await connecting; - - expect(singularCalls).toBe(1); - expect(listenerCalls).toBe(1); - }); - it("App receives host info and capabilities after connect", async () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); @@ -141,16 +89,10 @@ describe("App <-> AppBridge integration", () => { const hostCaps = app.getHostCapabilities(); expect(hostCaps).toEqual(testHostCapabilities); - expect(bridge.getCapabilities()).toEqual(testHostCapabilities); }); it("Bridge receives app info and capabilities after initialization", async () => { - const appCapabilities = { - experimental: { - "com.example/app-extension": { version: 1 }, - }, - tools: { listChanged: true }, - }; + const appCapabilities = { tools: { listChanged: true } }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); await bridge.connect(bridgeTransport); @@ -570,37 +512,12 @@ describe("App <-> AppBridge integration", () => { }; await app.connect(appTransport); - expect( + await expect( app.updateModelContext({ content: [{ type: "text", text: "Test" }], }), ).rejects.toThrow("Context update failed"); }); - - it("app.requestTeardown allows host to initiate teardown flow", async () => { - const events: string[] = []; - - bridge.onrequestteardown = async () => { - events.push("teardown-requested"); - await bridge.teardownResource({}); - events.push("teardown-complete"); - }; - - app.onteardown = async () => { - events.push("persist-unsaved-state"); - return {}; - }; - - await app.connect(appTransport); - await app.requestTeardown(); - await flush(); - - expect(events).toEqual([ - "teardown-requested", - "persist-unsaved-state", - "teardown-complete", - ]); - }); }); describe("App -> Host requests", () => { @@ -667,30 +584,6 @@ describe("App <-> AppBridge integration", () => { expect(result.isError).toBe(true); }); - - it("rejects invalid params for a custom ui request", async () => { - bridge.onopenlink = async () => ({}); - await app.connect(appTransport); - - await expect( - app.request( - { - method: "ui/open-link", - params: { url: 42 }, - }, - McpUiOpenLinkResultSchema, - ), - ).rejects.toThrow(); - }); - - it("rejects invalid results from a custom ui handler", async () => { - bridge.onopenlink = async () => ({ isError: "yes" }) as never; - await app.connect(appTransport); - - await expect( - app.openLink({ url: "https://example.com" }), - ).rejects.toThrow(); - }); }); describe("deprecated method aliases", () => { @@ -720,1109 +613,21 @@ describe("App <-> AppBridge integration", () => { }); }); - describe("double-connect guard", () => { - it("AppBridge.connect() throws if already connected", async () => { - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - // Attempting to connect again with a different transport should throw - const [, secondBridgeTransport] = InMemoryTransport.createLinkedPair(); - expect(bridge.connect(secondBridgeTransport)).rejects.toThrow( - "AppBridge is already connected", - ); - }); - - it("App.connect() throws if already connected", async () => { - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - // Attempting to connect again should throw - const [secondAppTransport] = InMemoryTransport.createLinkedPair(); - expect(app.connect(secondAppTransport)).rejects.toThrow( - "App is already connected", - ); - }); - - it("AppBridge.connect() throws even when called with the same transport", async () => { - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - // Should throw regardless of whether it's the same or a different transport - expect(bridge.connect(bridgeTransport)).rejects.toThrow( - "AppBridge is already connected", - ); - }); - }); - describe("ping", () => { it("App responds to ping from bridge", async () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); // Bridge can send ping via the protocol's request method - const result = await bridge.request({ method: "ping", params: {} }); + const result = await bridge.request( + { method: "ping", params: {} }, + EmptyResultSchema, + ); expect(result).toEqual({}); }); }); - describe("App tool registration", () => { - beforeEach(async () => { - app = new App( - testAppInfo, - { tools: { listChanged: true } }, - { autoResize: false }, - ); - await bridge.connect(bridgeTransport); - }); - - it("registerTool creates a registered tool", async () => { - const InputSchema = z.object({ name: z.string() }); - const OutputSchema = z.object({ greeting: z.string() }); - - const tool = app.registerTool( - "greet", - { - title: "Greet User", - description: "Greets a user by name", - inputSchema: InputSchema, - outputSchema: OutputSchema, - }, - async (args: any) => ({ - content: [{ type: "text" as const, text: `Hello, ${args.name}!` }], - structuredContent: { greeting: `Hello, ${args.name}!` }, - }), - ); - - expect(tool.title).toBe("Greet User"); - expect(tool.description).toBe("Greets a user by name"); - expect(tool.enabled).toBe(true); - }); - - it("registered tool can be enabled and disabled", async () => { - await app.connect(appTransport); - - const tool = app.registerTool( - "test-tool", - { - description: "Test tool", - }, - async (_extra: any) => ({ content: [] }), - ); - - expect(tool.enabled).toBe(true); - - tool.disable(); - expect(tool.enabled).toBe(false); - - tool.enable(); - expect(tool.enabled).toBe(true); - }); - - it("registered tool can be updated", async () => { - await app.connect(appTransport); - - const tool = app.registerTool( - "test-tool", - { - description: "Original description", - }, - async (_extra: any) => ({ content: [] }), - ); - - expect(tool.description).toBe("Original description"); - - tool.update({ description: "Updated description" }); - expect(tool.description).toBe("Updated description"); - }); - - it("registered tool can be removed", async () => { - await app.connect(appTransport); - - const tool = app.registerTool( - "test-tool", - { - description: "Test tool", - }, - async (_extra: any) => ({ content: [] }), - ); - - tool.remove(); - // Tool should no longer be registered (internal check) - }); - - it("registerTool throws on duplicate name", () => { - app.registerTool("dup", {}, async () => ({ content: [] })); - expect(() => - app.registerTool("dup", {}, async () => ({ content: [] })), - ).toThrow(/already registered/); - }); - - it("enable/disable/update/remove pre-connect do not throw", () => { - const tool = app.registerTool("t", {}, async () => ({ content: [] })); - expect(() => tool.disable()).not.toThrow(); - expect(() => tool.enable()).not.toThrow(); - expect(() => tool.update({ description: "x" })).not.toThrow(); - expect(() => tool.remove()).not.toThrow(); - }); - - it("callback without inputSchema receives extra as first arg", async () => { - await app.connect(appTransport); - let receivedExtra: any; - app.registerTool("noargs", {}, async (extra: any) => { - receivedExtra = extra; - return { content: [] }; - }); - await bridge.callTool({ name: "noargs", arguments: {} }); - expect(receivedExtra).toBeDefined(); - expect(receivedExtra.mcpReq.signal).toBeInstanceOf(AbortSignal); - }); - - it("isError result skips output schema validation", async () => { - await app.connect(appTransport); - app.registerTool( - "errs", - { outputSchema: z.object({ ok: z.boolean() }) }, - async () => ({ - content: [{ type: "text" as const, text: "boom" }], - isError: true, - }), - ); - const res = await bridge.callTool({ name: "errs", arguments: {} }); - expect(res.isError).toBe(true); - expect(res.structuredContent).toBeUndefined(); - }); - - it("stale handle remove() does not delete a re-registered tool", async () => { - const t1 = app.registerTool("phoenix", {}, async () => ({ content: [] })); - t1.remove(); - app.registerTool("phoenix", {}, async () => ({ content: [] })); - t1.remove(); - await app.connect(appTransport); - const list = await bridge.listTools({}); - expect(list.tools.map((t) => t.name)).toContain("phoenix"); - }); - - it("host omitting arguments defaults to empty object", async () => { - await app.connect(appTransport); - let received: unknown; - app.registerTool( - "noargs2", - { inputSchema: z.object({}) }, - async (args) => { - received = args; - return { content: [] }; - }, - ); - await bridge.callTool({ name: "noargs2" }); - expect(received).toEqual({}); - }); - - it("update({inputSchema}) is honored by handler validation", async () => { - await app.connect(appTransport); - const tool = app.registerTool( - "evolving", - { inputSchema: z.object({ a: z.string() }) }, - async (args: any) => ({ - content: [{ type: "text" as const, text: JSON.stringify(args) }], - }), - ); - expect( - bridge.callTool({ name: "evolving", arguments: { a: 123 } }), - ).rejects.toThrow(/Invalid input/); - tool.update({ inputSchema: z.object({ a: z.number() }) }); - const result = await bridge.callTool({ - name: "evolving", - arguments: { a: 123 }, - }); - expect(result.content[0]).toEqual({ type: "text", text: '{"a":123}' }); - }); - - it("tool throws error when disabled and called", async () => { - await app.connect(appTransport); - - const tool = app.registerTool( - "test-tool", - { - description: "Test tool", - }, - async (_extra: any) => ({ content: [] }), - ); - - tool.disable(); - - const mockExtra = { - signal: new AbortController().signal, - requestId: "test", - sendNotification: async () => {}, - sendRequest: async () => ({}), - } as any; - - expect((tool.handler as any)(mockExtra)).rejects.toThrow( - "Tool test-tool is disabled", - ); - }); - - it("tool validates input schema", async () => { - const InputSchema = z.object({ name: z.string() }); - - const tool = app.registerTool( - "greet", - { - inputSchema: InputSchema, - }, - async (args: any) => ({ - content: [{ type: "text" as const, text: `Hello, ${args.name}!` }], - }), - ); - - // Create a mock RequestHandlerExtra - const mockExtra = { - signal: new AbortController().signal, - requestId: "test", - sendNotification: async () => {}, - sendRequest: async () => ({}), - } as any; - - // Valid input should work - expect( - (tool.handler as any)({ name: "Alice" }, mockExtra), - ).resolves.toBeDefined(); - - // Invalid input should fail - expect( - (tool.handler as any)({ invalid: "field" }, mockExtra), - ).rejects.toThrow("Invalid input for tool greet"); - }); - - it("tool validates output schema", async () => { - const OutputSchema = z.object({ greeting: z.string() }); - - const tool = app.registerTool( - "greet", - { - outputSchema: OutputSchema, - }, - async (_extra: any) => ({ - content: [{ type: "text" as const, text: "Hello!" }], - structuredContent: { greeting: "Hello!" }, - }), - ); - - // Create a mock RequestHandlerExtra - const mockExtra = { - signal: new AbortController().signal, - requestId: "test", - sendNotification: async () => {}, - sendRequest: async () => ({}), - } as any; - - // Valid output should work - expect((tool.handler as any)(mockExtra)).resolves.toBeDefined(); - }); - - it("tool enable/disable/update/remove trigger sendToolListChanged", async () => { - await app.connect(appTransport); - - const tool = app.registerTool( - "test-tool", - { - description: "Test tool", - }, - async (_extra: any) => ({ content: [] }), - ); - - // The methods should not throw when connected - expect(() => tool.disable()).not.toThrow(); - expect(() => tool.enable()).not.toThrow(); - expect(() => tool.update({ description: "Updated" })).not.toThrow(); - expect(() => tool.remove()).not.toThrow(); - }); - }); - - describe("AppBridge -> App tool requests", () => { - beforeEach(async () => { - await bridge.connect(bridgeTransport); - }); - - it("bridge.callTool calls app.oncalltool handler", async () => { - // App needs tool capabilities to handle tool calls - const appCapabilities = { tools: {} }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - const receivedCalls: unknown[] = []; - - app.oncalltool = async (params) => { - receivedCalls.push(params); - return { - content: [{ type: "text", text: `Executed: ${params.name}` }], - }; - }; - - await app.connect(appTransport); - - const result = await bridge.callTool({ - name: "test-tool", - arguments: { foo: "bar" }, - }); - - expect(receivedCalls).toHaveLength(1); - expect(receivedCalls[0]).toMatchObject({ - name: "test-tool", - arguments: { foo: "bar" }, - }); - expect(result.content).toEqual([ - { type: "text", text: "Executed: test-tool" }, - ]); - }); - - it("bridge.listTools calls app.onlisttools handler", async () => { - // App needs tool capabilities to handle tool list requests - const appCapabilities = { tools: {} }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - const receivedCalls: unknown[] = []; - - app.onlisttools = async (params, _extra) => { - receivedCalls.push(params); - return { - tools: [ - { - name: "tool1", - description: "First tool", - inputSchema: { type: "object", properties: {} }, - }, - { - name: "tool2", - description: "Second tool", - inputSchema: { type: "object", properties: {} }, - }, - { - name: "tool3", - description: "Third tool", - inputSchema: { type: "object", properties: {} }, - }, - ], - }; - }; - - await app.connect(appTransport); - - const result = await bridge.listTools({}); - - expect(receivedCalls).toHaveLength(1); - expect(result.tools).toHaveLength(3); - expect(result.tools[0].name).toBe("tool1"); - expect(result.tools[1].name).toBe("tool2"); - expect(result.tools[2].name).toBe("tool3"); - }); - }); - - describe("App tool capabilities", () => { - it("App with tool capabilities can handle tool calls", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - const receivedCalls: unknown[] = []; - app.oncalltool = async (params) => { - receivedCalls.push(params); - return { - content: [{ type: "text", text: "Success" }], - }; - }; - - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - await bridge.callTool({ - name: "test-tool", - arguments: {}, - }); - - expect(receivedCalls).toHaveLength(1); - }); - - it("registered tool is invoked via oncalltool", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - const tool = app.registerTool( - "greet", - { - description: "Greets user", - inputSchema: z.object({ name: z.string() }), - }, - async (args: any) => ({ - content: [{ type: "text" as const, text: `Hello, ${args.name}!` }], - }), - ); - - app.oncalltool = async (params, extra) => { - if (params.name === "greet") { - return await (tool.handler as any)(params.arguments || {}, extra); - } - throw new Error(`Unknown tool: ${params.name}`); - }; - - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - const result = await bridge.callTool({ - name: "greet", - arguments: { name: "Alice" }, - }); - - expect(result.content).toEqual([{ type: "text", text: "Hello, Alice!" }]); - }); - }); - - describe("Automatic request handlers", () => { - beforeEach(async () => { - await bridge.connect(bridgeTransport); - }); - - describe("oncalltool automatic handler", () => { - it("automatically calls registered tool without manual oncalltool setup", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - // Register a tool - app.registerTool( - "greet", - { - description: "Greets user", - inputSchema: z.object({ name: z.string() }), - }, - async (args: any) => ({ - content: [{ type: "text" as const, text: `Hello, ${args.name}!` }], - }), - ); - - await app.connect(appTransport); - - // Call the tool through bridge - should work automatically - const result = await bridge.callTool({ - name: "greet", - arguments: { name: "Bob" }, - }); - - expect(result.content).toEqual([{ type: "text", text: "Hello, Bob!" }]); - }); - - it("throws error when calling non-existent tool", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - // Register a tool to initialize handlers - app.registerTool("existing-tool", {}, async (_args: any) => ({ - content: [], - })); - - await app.connect(appTransport); - - // Try to call a tool that doesn't exist - expect( - bridge.callTool({ - name: "nonexistent", - arguments: {}, - }), - ).rejects.toThrow("Tool nonexistent not found"); - }); - - it("handles multiple registered tools correctly", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - // Register multiple tools - app.registerTool( - "add", - { - description: "Add two numbers", - inputSchema: z.object({ a: z.number(), b: z.number() }), - }, - async (args: any) => ({ - content: [ - { - type: "text" as const, - text: `Result: ${args.a + args.b}`, - }, - ], - structuredContent: { result: args.a + args.b }, - }), - ); - - app.registerTool( - "multiply", - { - description: "Multiply two numbers", - inputSchema: z.object({ a: z.number(), b: z.number() }), - }, - async (args: any) => ({ - content: [ - { - type: "text" as const, - text: `Result: ${args.a * args.b}`, - }, - ], - structuredContent: { result: args.a * args.b }, - }), - ); - - await app.connect(appTransport); - - // Call first tool - const addResult = await bridge.callTool({ - name: "add", - arguments: { a: 5, b: 3 }, - }); - expect(addResult.content).toEqual([ - { type: "text", text: "Result: 8" }, - ]); - - // Call second tool - const multiplyResult = await bridge.callTool({ - name: "multiply", - arguments: { a: 5, b: 3 }, - }); - expect(multiplyResult.content).toEqual([ - { type: "text", text: "Result: 15" }, - ]); - }); - - it("respects tool enable/disable state", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - const tool = app.registerTool( - "test-tool", - { - description: "Test tool", - }, - async (_args: any) => ({ - content: [{ type: "text" as const, text: "Success" }], - }), - ); - - await app.connect(appTransport); - - // Should work when enabled - expect( - bridge.callTool({ name: "test-tool", arguments: {} }), - ).resolves.toBeDefined(); - - // Disable tool - tool.disable(); - - // Should throw when disabled - expect( - bridge.callTool({ name: "test-tool", arguments: {} }), - ).rejects.toThrow("Tool test-tool is disabled"); - }); - - it("validates input schema through automatic handler", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - app.registerTool( - "strict-tool", - { - description: "Requires specific input", - inputSchema: z.object({ - required: z.string(), - optional: z.number().optional(), - }) as any, - }, - async (args: any) => ({ - content: [{ type: "text" as const, text: `Got: ${args.required}` }], - }), - ); - - await app.connect(appTransport); - - // Valid input should work - expect( - bridge.callTool({ - name: "strict-tool", - arguments: { required: "hello" }, - }), - ).resolves.toBeDefined(); - - // Invalid input should fail - expect( - bridge.callTool({ - name: "strict-tool", - arguments: { wrong: "field" }, - }), - ).rejects.toThrow("Invalid input for tool strict-tool"); - }); - - it("validates output schema through automatic handler", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - app.registerTool( - "validated-output", - { - description: "Has output validation", - outputSchema: z.object({ - status: z.enum(["success", "error"]), - }) as any, - }, - async (_args: any) => ({ - content: [{ type: "text" as const, text: "Done" }], - structuredContent: { status: "success" }, - }), - ); - - await app.connect(appTransport); - - // Valid output should work - const result = await bridge.callTool({ - name: "validated-output", - arguments: {}, - }); - expect(result).toBeDefined(); - }); - - it("works after tool is removed and re-registered", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - const tool = app.registerTool( - "dynamic-tool", - {}, - async (_args: any) => ({ - content: [{ type: "text" as const, text: "Version 1" }], - }), - ); - - await app.connect(appTransport); - - // First version - let result = await bridge.callTool({ - name: "dynamic-tool", - arguments: {}, - }); - expect(result.content).toEqual([{ type: "text", text: "Version 1" }]); - - // Remove tool - tool.remove(); - - // Should fail after removal - expect( - bridge.callTool({ name: "dynamic-tool", arguments: {} }), - ).rejects.toThrow("Tool dynamic-tool not found"); - - // Re-register with different behavior - app.registerTool("dynamic-tool", {}, async (_args: any) => ({ - content: [{ type: "text" as const, text: "Version 2" }], - })); - - // Should work with new version - result = await bridge.callTool({ - name: "dynamic-tool", - arguments: {}, - }); - expect(result.content).toEqual([{ type: "text", text: "Version 2" }]); - }); - }); - - describe("onlisttools automatic handler", () => { - it("automatically returns list of registered tool names", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - // Register some tools - app.registerTool("tool1", {}, async (_args: any) => ({ - content: [], - })); - app.registerTool("tool2", {}, async (_args: any) => ({ - content: [], - })); - app.registerTool("tool3", {}, async (_args: any) => ({ - content: [], - })); - - await app.connect(appTransport); - - const result = await bridge.listTools({}); - - expect(result.tools).toHaveLength(3); - expect(result.tools.map((t) => t.name)).toContain("tool1"); - expect(result.tools.map((t) => t.name)).toContain("tool2"); - expect(result.tools.map((t) => t.name)).toContain("tool3"); - }); - - it("emits core MCP Tool fields (title, outputSchema only when provided)", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - app.registerTool( - "with-output", - { - title: "With Output", - description: "has structured output", - outputSchema: z.object({ ok: z.boolean() }), - }, - async () => ({ - content: [], - structuredContent: { ok: true }, - }), - ); - app.registerTool( - "no-output", - { description: "no structured output" }, - async () => ({ content: [] }), - ); - - await app.connect(appTransport); - const result = await bridge.listTools({}); - const byName = Object.fromEntries(result.tools.map((t) => [t.name, t])); - - expect(byName["with-output"].title).toBe("With Output"); - expect(byName["with-output"].inputSchema).toBeDefined(); - expect(byName["with-output"].outputSchema).toBeDefined(); - // outputSchema is optional in core MCP โ€” omitted when not declared - expect(byName["no-output"]).not.toHaveProperty("outputSchema"); - expect(byName["no-output"].inputSchema).toBeDefined(); - }); - - it("accepts any Standard Schema implementation, not only zod", async () => { - // Hand-rolled StandardSchemaWithJSON โ€” proves registerTool has no - // zod-specific runtime path. Any library implementing the spec - // (ArkType, Valibot, โ€ฆ) works the same way. - type Point = { x: number; y: number }; - const PointSchema = { - "~standard": { - version: 1 as const, - vendor: "test", - types: undefined as - | undefined - | { readonly input: Point; readonly output: Point }, - validate: (v: unknown) => - typeof v === "object" && - v !== null && - typeof (v as any).x === "number" && - typeof (v as any).y === "number" - ? { value: v as { x: number; y: number } } - : { issues: [{ message: "expected {x:number,y:number}" }] }, - jsonSchema: { - input: () => ({ - type: "object", - properties: { x: { type: "number" }, y: { type: "number" } }, - required: ["x", "y"], - }), - output: () => ({ - type: "object", - properties: { x: { type: "number" }, y: { type: "number" } }, - }), - }, - }, - }; - - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - app.registerTool( - "translate", - { inputSchema: PointSchema, outputSchema: PointSchema }, - async ({ x, y }) => ({ - content: [], - structuredContent: { x: x + 1, y: y + 1 }, - }), - ); - await app.connect(appTransport); - - const list = await bridge.listTools({}); - expect(list.tools[0].inputSchema).toEqual({ - type: "object", - properties: { x: { type: "number" }, y: { type: "number" } }, - required: ["x", "y"], - }); - expect(list.tools[0].outputSchema).toBeDefined(); - - const ok = await bridge.callTool({ - name: "translate", - arguments: { x: 1, y: 2 }, - }); - expect(ok.structuredContent).toEqual({ x: 2, y: 3 }); - - expect( - bridge.callTool({ name: "translate", arguments: { x: "bad" } }), - ).rejects.toThrow(/Invalid input for tool translate/); - }); - - it("rejects listTools when a tool schema does not implement Standard JSON Schema", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - app.registerTool( - "broken", - { - inputSchema: { - "~standard": { - version: 1 as const, - vendor: "mystery", - validate: () => ({ value: {} }), - }, - }, - }, - async () => ({ content: [] }), - ); - await app.connect(appTransport); - - expect(bridge.listTools({})).rejects.toThrow( - /does not implement Standard JSON Schema/, - ); - }); - - it("returns empty list when no tools registered", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - // Register a tool to ensure handlers are initialized - const dummyTool = app.registerTool("dummy", {}, async () => ({ - content: [], - })); - - await app.connect(appTransport); - - // Remove the tool after connecting - dummyTool.remove(); - - const result = await bridge.listTools({}); - - expect(result.tools).toEqual([]); - }); - - it("updates list when tools are added", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - await app.connect(appTransport); - - // Register then remove a tool to initialize handlers - const dummy = app.registerTool("init", {}, async () => ({ - content: [], - })); - dummy.remove(); - - // Initially no tools - let result = await bridge.listTools({}); - expect(result.tools).toEqual([]); - - // Add a tool - app.registerTool("new-tool", {}, async (_args: any) => ({ - content: [], - })); - - // Should now include the new tool - result = await bridge.listTools({}); - expect(result.tools.map((t) => t.name)).toEqual(["new-tool"]); - - // Add another tool - app.registerTool("another-tool", {}, async (_args: any) => ({ - content: [], - })); - - // Should now include both tools - result = await bridge.listTools({}); - expect(result.tools).toHaveLength(2); - expect(result.tools.map((t) => t.name)).toContain("new-tool"); - expect(result.tools.map((t) => t.name)).toContain("another-tool"); - }); - - it("updates list when tools are removed", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - const tool1 = app.registerTool("tool1", {}, async (_args: any) => ({ - content: [], - })); - const tool2 = app.registerTool("tool2", {}, async (_args: any) => ({ - content: [], - })); - app.registerTool("tool3", {}, async (_args: any) => ({ - content: [], - })); - - await app.connect(appTransport); - - // Initially all three tools - let result = await bridge.listTools({}); - expect(result.tools).toHaveLength(3); - - // Remove one tool - tool2.remove(); - - // Should now have two tools - result = await bridge.listTools({}); - expect(result.tools).toHaveLength(2); - expect(result.tools.map((t) => t.name)).toContain("tool1"); - expect(result.tools.map((t) => t.name)).toContain("tool3"); - expect(result.tools.map((t) => t.name)).not.toContain("tool2"); - - // Remove another tool - tool1.remove(); - - // Should now have one tool - result = await bridge.listTools({}); - expect(result.tools.map((t) => t.name)).toEqual(["tool3"]); - }); - - it("only includes enabled tools in list", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - app.registerTool("enabled-tool", {}, async (_args: any) => ({ - content: [], - })); - const tool2 = app.registerTool( - "disabled-tool", - {}, - async (_args: any) => ({ - content: [], - }), - ); - - await app.connect(appTransport); - - // Disable one tool after connecting - tool2.disable(); - - const result = await bridge.listTools({}); - - // Only enabled tool should be in the list - expect(result.tools).toHaveLength(1); - expect(result.tools.map((t) => t.name)).toContain("enabled-tool"); - expect(result.tools.map((t) => t.name)).not.toContain("disabled-tool"); - }); - }); - - describe("Integration: automatic handlers with tool lifecycle", () => { - it("handles complete tool lifecycle: register -> call -> update -> call -> remove", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - await app.connect(appTransport); - - // Register tool - const tool = app.registerTool( - "counter", - { - description: "A counter tool", - }, - async (_args: any) => ({ - content: [{ type: "text" as const, text: "Count: 1" }], - structuredContent: { count: 1 }, - }), - ); - - // List should include the tool - let listResult = await bridge.listTools({}); - expect(listResult.tools.map((t) => t.name)).toContain("counter"); - - // Call the tool - let callResult = await bridge.callTool({ - name: "counter", - arguments: {}, - }); - expect(callResult.content).toEqual([ - { type: "text", text: "Count: 1" }, - ]); - - // Update tool description - tool.update({ description: "An updated counter tool" }); - - // Should still be callable - callResult = await bridge.callTool({ - name: "counter", - arguments: {}, - }); - expect(callResult).toBeDefined(); - - // Remove tool - tool.remove(); - - // Should no longer be in list - listResult = await bridge.listTools({}); - expect(listResult.tools.map((t) => t.name)).not.toContain("counter"); - - // Should no longer be callable - expect( - bridge.callTool({ name: "counter", arguments: {} }), - ).rejects.toThrow("Tool counter not found"); - }); - - it("multiple apps can have separate tool registries", async () => { - const appCapabilities = { tools: { listChanged: true } }; - - // Create two separate apps - const app1 = new App( - { name: "App1", version: "1.0.0" }, - appCapabilities, - { autoResize: false }, - ); - const app2 = new App( - { name: "App2", version: "1.0.0" }, - appCapabilities, - { autoResize: false }, - ); - - // Create separate transports for each app - const [app1Transport, bridge1Transport] = - InMemoryTransport.createLinkedPair(); - const [app2Transport, bridge2Transport] = - InMemoryTransport.createLinkedPair(); - - const bridge1 = new AppBridge( - createMockClient() as Client, - testHostInfo, - testHostCapabilities, - ); - const bridge2 = new AppBridge( - createMockClient() as Client, - testHostInfo, - testHostCapabilities, - ); - - // Register different tools in each app - app1.registerTool("app1-tool", {}, async (_args: any) => ({ - content: [{ type: "text" as const, text: "From App1" }], - })); - - app2.registerTool("app2-tool", {}, async (_args: any) => ({ - content: [{ type: "text" as const, text: "From App2" }], - })); - - await bridge1.connect(bridge1Transport); - await bridge2.connect(bridge2Transport); - await app1.connect(app1Transport); - await app2.connect(app2Transport); - - // Each app should only see its own tools - const list1 = await bridge1.listTools({}); - expect(list1.tools.map((t) => t.name)).toEqual(["app1-tool"]); - - const list2 = await bridge2.listTools({}); - expect(list2.tools.map((t) => t.name)).toEqual(["app2-tool"]); - - // Each app should only be able to call its own tools - expect( - bridge1.callTool({ name: "app1-tool", arguments: {} }), - ).resolves.toBeDefined(); - - expect( - bridge1.callTool({ name: "app2-tool", arguments: {} }), - ).rejects.toThrow("Tool app2-tool not found"); - - // Clean up - await app1Transport.close(); - await bridge1Transport.close(); - await app2Transport.close(); - await bridge2Transport.close(); - }); - }); - }); - describe("AppBridge without MCP client (manual handlers)", () => { let app: App; let bridge: AppBridge; @@ -1871,349 +676,6 @@ describe("App <-> AppBridge integration", () => { expect(result.content).toEqual(resultContent); }); - it("oncreatesamplingmessage setter registers handler for sampling/createMessage requests", async () => { - // Re-create bridge with sampling capability so App's capability check passes - bridge = new AppBridge(null, testHostInfo, { - ...testHostCapabilities, - sampling: { tools: {} }, - }); - - const receivedParams: unknown[] = []; - bridge.oncreatesamplingmessage = async (params) => { - receivedParams.push(params); - return { - role: "assistant", - content: { type: "text", text: "Hello from the model" }, - model: "test-model", - stopReason: "endTurn", - }; - }; - - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - expect(app.getHostCapabilities()?.sampling?.tools).toEqual({}); - - const result = await app.createSamplingMessage({ - messages: [{ role: "user", content: { type: "text", text: "Hi" } }], - maxTokens: 50, - }); - - expect(receivedParams).toHaveLength(1); - expect(receivedParams[0]).toMatchObject({ maxTokens: 50 }); - expect(result.model).toEqual("test-model"); - expect(result.content).toEqual({ - type: "text", - text: "Hello from the model", - }); - }); - - it("ondownloadfile setter registers handler for ui/download-file requests", async () => { - const downloadParams = { - contents: [ - { - type: "resource" as const, - resource: { - uri: "file:///export.json", - mimeType: "application/json", - text: '{"key":"value"}', - }, - }, - ], - }; - const receivedRequests: unknown[] = []; - - bridge.ondownloadfile = async (params) => { - receivedRequests.push(params); - return {}; - }; - - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - const result = await app.downloadFile(downloadParams); - - expect(receivedRequests).toHaveLength(1); - expect(receivedRequests[0]).toMatchObject(downloadParams); - expect(result).toEqual({}); - }); - - it("callServerTool throws a helpful error when called with a string instead of params object", async () => { - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - expect( - // @ts-expect-error intentionally testing wrong usage - app.callServerTool("my_tool"), - ).rejects.toThrow( - 'callServerTool() expects an object as its first argument, but received a string ("my_tool"). ' + - 'Did you mean: callServerTool({ name: "my_tool", arguments: { ... } })?', - ); - }); - - describe("pre-handshake guard (claude-ai-mcp#149)", () => { - const guardMsg = - /called before connect\(\) completed the ui\/initialize handshake/; - let warnSpy: ReturnType<typeof spyOn<Console, "warn">>; - - beforeEach(() => { - warnSpy = spyOn(console, "warn").mockImplementation(() => {}); - }); - afterEach(() => { - warnSpy.mockRestore(); - }); - - const warnings = () => warnSpy.mock.calls.map((c) => String(c[0])); - - it("callServerTool warns when called before connect() completes", async () => { - bridge.oncalltool = async () => ({ content: [] }); - await bridge.connect(bridgeTransport); - - const connecting = app.connect(appTransport); - // Handshake is in flight; _initializedSent is still false. - await app.callServerTool({ name: "t", arguments: {} }).catch(() => {}); - - const appSide = warnings().filter((m) => guardMsg.test(m)); - expect(appSide).toHaveLength(1); - expect(appSide[0]).toContain("App.callServerTool()"); - - await connecting; - }); - - it("callServerTool does not warn after connect() resolves", async () => { - bridge.oncalltool = async () => ({ content: [] }); - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - await app.callServerTool({ name: "t", arguments: {} }); - - expect(warnings()).toEqual([]); - }); - - it("sendMessage and readServerResource also warn before handshake", async () => { - await app.sendMessage({ role: "user", content: [] }).catch(() => {}); - await app.readServerResource({ uri: "test://r" }).catch(() => {}); - - const appSide = warnings().filter((m) => guardMsg.test(m)); - expect(appSide).toHaveLength(2); - expect(appSide[0]).toContain("App.sendMessage()"); - expect(appSide[1]).toContain("App.readServerResource()"); - }); - - it("throws instead of warning when strict: true", async () => { - const strictApp = new App( - testAppInfo, - {}, - { autoResize: false, strict: true }, - ); - - await expect( - strictApp.callServerTool({ name: "t", arguments: {} }), - ).rejects.toThrow(guardMsg); - expect(warnings()).toEqual([]); - }); - - describe("late handler registration", () => { - const lateMsg = - /handler registered after connect\(\) completed the ui\/initialize handshake/; - - it("warns when ontoolresult is set after connect() resolves", async () => { - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - app.ontoolresult = () => {}; - - expect(warnings()).toHaveLength(1); - expect(warnings()[0]).toMatch(lateMsg); - expect(warnings()[0]).toContain('"toolresult"'); - }); - - it("warns when addEventListener('toolinput', โ€ฆ) is called after connect()", async () => { - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - app.addEventListener("toolinput", () => {}); - - expect(warnings()).toHaveLength(1); - expect(warnings()[0]).toContain('"toolinput"'); - }); - - it("does not warn for handlers set before connect()", async () => { - app.ontoolinput = () => {}; - app.addEventListener("toolresult", () => {}); - - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - expect(warnings()).toEqual([]); - }); - - it("does not warn for hostcontextchanged (repeating event)", async () => { - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - app.onhostcontextchanged = () => {}; - app.addEventListener("hostcontextchanged", () => {}); - - expect(warnings()).toEqual([]); - }); - - it("does not warn when clearing a handler (set to undefined)", async () => { - app.ontoolinput = () => {}; - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - app.ontoolinput = undefined; - - expect(warnings()).toEqual([]); - }); - - it("does not warn on re-registration when a handler existed pre-connect", async () => { - const h = () => {}; - app.addEventListener("toolresult", h); - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - // React-style: useEffect cleanup removes, dep change re-adds. - app.removeEventListener("toolresult", h); - app.addEventListener("toolresult", () => {}); - - expect(warnings().filter((m) => lateMsg.test(m))).toEqual([]); - }); - - it("warns once for the first late registration, not on subsequent ones", async () => { - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - app.addEventListener("toolinput", () => {}); - app.addEventListener("toolinput", () => {}); - app.addEventListener("toolinput", () => {}); - - const late = warnings().filter((m) => lateMsg.test(m)); - expect(late).toHaveLength(1); - expect(late[0]).toContain('"toolinput"'); - }); - - it("tracks first-handler per event independently", async () => { - app.addEventListener("toolinput", () => {}); - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - app.addEventListener("toolinput", () => {}); // had pre-connect handler โ†’ silent - app.addEventListener("toolresult", () => {}); // first reg, late โ†’ warns - - const late = warnings().filter((m) => lateMsg.test(m)); - expect(late).toHaveLength(1); - expect(late[0]).toContain('"toolresult"'); - }); - - it("throws instead of warning when strict: true", async () => { - const [strictAppT, strictBridgeT] = - InMemoryTransport.createLinkedPair(); - const strictBridge = new AppBridge( - createMockClient() as Client, - testHostInfo, - testHostCapabilities, - ); - const strictApp = new App( - testAppInfo, - {}, - { autoResize: false, strict: true }, - ); - // Pre-connect registration for toolcancelled โ€” later swap must not throw. - strictApp.addEventListener("toolcancelled", () => {}); - - await strictBridge.connect(strictBridgeT); - await strictApp.connect(strictAppT); - - expect(() => { - strictApp.ontoolresult = () => {}; - }).toThrow(lateMsg); - expect(() => { - strictApp.addEventListener("toolinput", () => {}); - }).toThrow(lateMsg); - // Swapping a handler that existed pre-connect is allowed under strict. - expect(() => { - strictApp.addEventListener("toolcancelled", () => {}); - }).not.toThrow(); - expect(warnings().filter((m) => lateMsg.test(m))).toEqual([]); - }); - }); - - it("AppBridge warns on a second ui/initialize (View double-mount)", async () => { - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - expect(warnings()).toEqual([]); - - // Simulate a second View instance re-running the handshake. - appTransport.send({ - jsonrpc: "2.0", - id: 99, - method: "ui/initialize", - params: { - protocolVersion: LATEST_PROTOCOL_VERSION, - appInfo: testAppInfo, - appCapabilities: {}, - }, - }); - await flush(); - - const doubleInit = warnings().filter((m) => - /second ui\/initialize/.test(m), - ); - expect(doubleInit).toHaveLength(1); - }); - - it("close() stops further notification delivery (StrictMode cleanup relies on this)", async () => { - const received: unknown[] = []; - app.addEventListener("toolresult", (r) => received.push(r)); - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - await bridge.sendToolResult({ - content: [{ type: "text", text: "before" }], - }); - expect(received).toHaveLength(1); - - await app.close(); - - await bridge - .sendToolResult({ content: [{ type: "text", text: "after" }] }) - .catch(() => {}); - expect(received).toHaveLength(1); - }); - - it("AppBridge warns on tools/call from a View that skipped the handshake", async () => { - bridge.oncalltool = async () => ({ content: [] }); - await bridge.connect(bridgeTransport); - - // Simulate a hand-rolled View (no SDK, no handshake) sending tools/call. - await appTransport.start(); - appTransport.send({ - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { name: "t", arguments: {} }, - }); - await flush(); - - expect(warnings()).toHaveLength(1); - expect(warnings()[0]).toContain( - "received 'tools/call' before ui/notifications/initialized", - ); - }); - - it("AppBridge does not warn after initialized is received", async () => { - bridge.oncalltool = async () => ({ content: [] }); - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - await app.callServerTool({ name: "t", arguments: {} }); - - expect(warnings()).toEqual([]); - }); - }); - it("onlistresources setter registers handler for resources/list requests", async () => { const requestParams = {}; const resources = [{ uri: "test://resource", name: "Test" }]; @@ -2228,37 +690,16 @@ describe("App <-> AppBridge integration", () => { await app.connect(appTransport); // App sends resources/list request via the protocol's request method - const result = await app.request({ - method: "resources/list", - params: requestParams, - }); + const result = await app.request( + { method: "resources/list", params: requestParams }, + ListResourcesResultSchema, + ); expect(receivedRequests).toHaveLength(1); expect(receivedRequests[0]).toMatchObject(requestParams); expect(result.resources).toEqual(resources); }); - it("onlistresources handles listServerResources() calls from App", async () => { - const resources = [ - { uri: "test://res-1", name: "Resource 1" }, - { uri: "test://res-2", name: "Resource 2", mimeType: "video/mp4" }, - ]; - const receivedRequests: unknown[] = []; - - bridge.onlistresources = async (params) => { - receivedRequests.push(params); - return { resources }; - }; - - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - const result = await app.listServerResources(); - - expect(receivedRequests).toHaveLength(1); - expect(result.resources).toEqual(resources); - }); - it("onreadresource setter registers handler for resources/read requests", async () => { const requestParams = { uri: "test://resource" }; const contents = [{ uri: "test://resource", text: "content" }]; @@ -2272,36 +713,10 @@ describe("App <-> AppBridge integration", () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); - const result = await app.request({ - method: "resources/read", - params: requestParams, - }); - - expect(receivedRequests).toHaveLength(1); - expect(receivedRequests[0]).toMatchObject(requestParams); - expect(result.contents).toEqual(contents); - }); - - it("onreadresource handles readServerResource() calls from App", async () => { - const requestParams = { uri: "videos://bunny-1mb" }; - const contents = [ - { - uri: "videos://bunny-1mb", - blob: "dmlkZW9kYXRh", - mimeType: "video/mp4", - }, - ]; - const receivedRequests: unknown[] = []; - - bridge.onreadresource = async (params) => { - receivedRequests.push(params); - return { contents }; - }; - - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - const result = await app.readServerResource(requestParams); + const result = await app.request( + { method: "resources/read", params: requestParams }, + ReadResourceResultSchema, + ); expect(receivedRequests).toHaveLength(1); expect(receivedRequests[0]).toMatchObject(requestParams); @@ -2323,10 +738,10 @@ describe("App <-> AppBridge integration", () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); - const result = await app.request({ - method: "resources/templates/list", - params: requestParams, - }); + const result = await app.request( + { method: "resources/templates/list", params: requestParams }, + ListResourceTemplatesResultSchema, + ); expect(receivedRequests).toHaveLength(1); expect(receivedRequests[0]).toMatchObject(requestParams); @@ -2346,10 +761,10 @@ describe("App <-> AppBridge integration", () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); - const result = await app.request({ - method: "prompts/list", - params: requestParams, - }); + const result = await app.request( + { method: "prompts/list", params: requestParams }, + ListPromptsResultSchema, + ); expect(receivedRequests).toHaveLength(1); expect(receivedRequests[0]).toMatchObject(requestParams); @@ -2358,13 +773,9 @@ describe("App <-> AppBridge integration", () => { it("sendToolListChanged sends notification to app", async () => { const receivedNotifications: unknown[] = []; - bridge.oncalltool = async () => ({ content: [] }); - app.setNotificationHandler( - "notifications/tools/list_changed", - (notification) => { - receivedNotifications.push(notification.params); - }, - ); + app.setNotificationHandler(ToolListChangedNotificationSchema, (n) => { + receivedNotifications.push(n.params); + }); await bridge.connect(bridgeTransport); await app.connect(appTransport); @@ -2377,13 +788,9 @@ describe("App <-> AppBridge integration", () => { it("sendResourceListChanged sends notification to app", async () => { const receivedNotifications: unknown[] = []; - bridge.onlistresources = async () => ({ resources: [] }); - app.setNotificationHandler( - "notifications/resources/list_changed", - (notification) => { - receivedNotifications.push(notification.params); - }, - ); + app.setNotificationHandler(ResourceListChangedNotificationSchema, (n) => { + receivedNotifications.push(n.params); + }); await bridge.connect(bridgeTransport); await app.connect(appTransport); @@ -2396,13 +803,9 @@ describe("App <-> AppBridge integration", () => { it("sendPromptListChanged sends notification to app", async () => { const receivedNotifications: unknown[] = []; - bridge.onlistprompts = async () => ({ prompts: [] }); - app.setNotificationHandler( - "notifications/prompts/list_changed", - (notification) => { - receivedNotifications.push(notification.params); - }, - ); + app.setNotificationHandler(PromptListChangedNotificationSchema, (n) => { + receivedNotifications.push(n.params); + }); await bridge.connect(bridgeTransport); await app.connect(appTransport); @@ -2415,184 +818,6 @@ describe("App <-> AppBridge integration", () => { }); }); -describe("AppBridge outer v2 Client proxy", () => { - let outerClient: Client; - let outerServer: Server; - let outerClientTransport: InMemoryTransport; - let outerServerTransport: InMemoryTransport; - let outerInitializeCount: number; - - beforeEach(async () => { - [outerClientTransport, outerServerTransport] = - InMemoryTransport.createLinkedPair(); - outerInitializeCount = 0; - const originalSend = outerClientTransport.send.bind(outerClientTransport); - outerClientTransport.send = async (message, options) => { - if ("method" in message && message.method === "initialize") { - outerInitializeCount++; - } - return originalSend(message, options); - }; - - outerServer = new Server( - { name: "ActualServer", version: "1.0.0" }, - { - capabilities: { - tools: { listChanged: true }, - resources: { listChanged: true }, - prompts: { listChanged: true }, - }, - supportedProtocolVersions: ["2025-11-25"], - }, - ); - outerServer.setRequestHandler("tools/call", async (request) => ({ - content: [ - { - type: "text", - text: `outer:${request.params.name}`, - }, - ], - })); - - outerClient = new Client( - { name: "HostOuterClient", version: "1.0.0" }, - { - capabilities: {}, - versionNegotiation: { mode: "legacy" }, - supportedProtocolVersions: ["2025-11-25"], - }, - ); - await outerServer.connect(outerServerTransport); - await outerClient.connect(outerClientTransport); - }); - - afterEach(async () => { - await outerClient.close().catch(() => {}); - await outerServer.close().catch(() => {}); - }); - - it("proxies through the outer Client without reconnecting it", async () => { - const bridge = new AppBridge( - outerClient, - testHostInfo, - testHostCapabilities, - ); - const app = new App(testAppInfo, {}, { autoResize: false }); - let [appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair(); - - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - expect( - await app.callServerTool({ name: "first", arguments: {} }), - ).toMatchObject({ - content: [{ type: "text", text: "outer:first" }], - }); - expect(outerInitializeCount).toBe(1); - - await app.close(); - await bridge.close(); - [appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair(); - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - expect( - await app.callServerTool({ name: "second", arguments: {} }), - ).toMatchObject({ - content: [{ type: "text", text: "outer:second" }], - }); - expect(outerInitializeCount).toBe(1); - expect(outerClient.getServerVersion()).toMatchObject({ - name: "ActualServer", - version: "1.0.0", - }); - - await app.close(); - await bridge.close(); - }); - - it("propagates View cancellation to the actual outer Server", async () => { - let started!: () => void; - let aborted!: () => void; - const startedPromise = new Promise<void>((resolve) => { - started = resolve; - }); - const abortedPromise = new Promise<void>((resolve) => { - aborted = resolve; - }); - outerServer.setRequestHandler("tools/call", async (_request, context) => { - started(); - await new Promise<void>((resolve) => { - context.mcpReq.signal.addEventListener( - "abort", - () => { - aborted(); - resolve(); - }, - { once: true }, - ); - }); - return { content: [] }; - }); - - const bridge = new AppBridge( - outerClient, - testHostInfo, - testHostCapabilities, - ); - const app = new App(testAppInfo, {}, { autoResize: false }); - const [appTransport, bridgeTransport] = - InMemoryTransport.createLinkedPair(); - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - const controller = new AbortController(); - const result = app.callServerTool( - { name: "cancel-me", arguments: {} }, - { signal: controller.signal }, - ); - await startedPromise; - controller.abort("test cancellation"); - await abortedPromise; - await expect(result).rejects.toThrow(); - - await app.close(); - await bridge.close(); - }); - - it("forwards outer list-changed notifications to the App", async () => { - const bridge = new AppBridge( - outerClient, - testHostInfo, - testHostCapabilities, - ); - const app = new App(testAppInfo, {}, { autoResize: false }); - const [appTransport, bridgeTransport] = - InMemoryTransport.createLinkedPair(); - const received: string[] = []; - app.setNotificationHandler("notifications/tools/list_changed", () => { - received.push("tools"); - }); - app.setNotificationHandler("notifications/resources/list_changed", () => { - received.push("resources"); - }); - app.setNotificationHandler("notifications/prompts/list_changed", () => { - received.push("prompts"); - }); - - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - await outerServer.sendToolListChanged(); - await outerServer.sendResourceListChanged(); - await outerServer.sendPromptListChanged(); - await flush(); - - expect(received).toEqual(["tools", "resources", "prompts"]); - - await app.close(); - await bridge.close(); - }); -}); - describe("getToolUiResourceUri", () => { describe("new nested format (_meta.ui.resourceUri)", () => { it("extracts resourceUri from _meta.ui.resourceUri", () => { @@ -2696,437 +921,3 @@ describe("getToolUiResourceUri", () => { }); }); }); - -describe("isToolVisibilityModelOnly", () => { - describe("returns true", () => { - it("when visibility is exactly ['model']", () => { - const tool = { - name: "test-tool", - _meta: { ui: { visibility: ["model"] } }, - }; - expect(isToolVisibilityModelOnly(tool)).toBe(true); - }); - }); - - describe("returns false", () => { - it("when visibility is ['app']", () => { - const tool = { - name: "test-tool", - _meta: { ui: { visibility: ["app"] } }, - }; - expect(isToolVisibilityModelOnly(tool)).toBe(false); - }); - - it("when visibility is ['model', 'app']", () => { - const tool = { - name: "test-tool", - _meta: { ui: { visibility: ["model", "app"] } }, - }; - expect(isToolVisibilityModelOnly(tool)).toBe(false); - }); - - it("when visibility is ['app', 'model']", () => { - const tool = { - name: "test-tool", - _meta: { ui: { visibility: ["app", "model"] } }, - }; - expect(isToolVisibilityModelOnly(tool)).toBe(false); - }); - - it("when visibility is empty array", () => { - const tool = { - name: "test-tool", - _meta: { ui: { visibility: [] } }, - }; - expect(isToolVisibilityModelOnly(tool)).toBe(false); - }); - - it("when visibility is undefined", () => { - const tool = { - name: "test-tool", - _meta: { ui: {} }, - }; - expect(isToolVisibilityModelOnly(tool)).toBe(false); - }); - - it("when _meta.ui is missing", () => { - const tool = { - name: "test-tool", - _meta: {}, - }; - expect(isToolVisibilityModelOnly(tool)).toBe(false); - }); - - it("when _meta is missing", () => { - const tool = { name: "test-tool" }; - expect(isToolVisibilityModelOnly(tool)).toBe(false); - }); - - it("when tool has resourceUri but no visibility", () => { - const tool = { - name: "test-tool", - _meta: { ui: { resourceUri: "ui://server/app.html" } }, - }; - expect(isToolVisibilityModelOnly(tool)).toBe(false); - }); - }); -}); - -describe("isToolVisibilityAppOnly", () => { - describe("returns true", () => { - it("when visibility is exactly ['app']", () => { - const tool = { - name: "test-tool", - _meta: { ui: { visibility: ["app"] } }, - }; - expect(isToolVisibilityAppOnly(tool)).toBe(true); - }); - }); - - describe("returns false", () => { - it("when visibility is ['model']", () => { - const tool = { - name: "test-tool", - _meta: { ui: { visibility: ["model"] } }, - }; - expect(isToolVisibilityAppOnly(tool)).toBe(false); - }); - - it("when visibility is ['model', 'app']", () => { - const tool = { - name: "test-tool", - _meta: { ui: { visibility: ["model", "app"] } }, - }; - expect(isToolVisibilityAppOnly(tool)).toBe(false); - }); - - it("when visibility is ['app', 'model']", () => { - const tool = { - name: "test-tool", - _meta: { ui: { visibility: ["app", "model"] } }, - }; - expect(isToolVisibilityAppOnly(tool)).toBe(false); - }); - - it("when visibility is empty array", () => { - const tool = { - name: "test-tool", - _meta: { ui: { visibility: [] } }, - }; - expect(isToolVisibilityAppOnly(tool)).toBe(false); - }); - - it("when visibility is undefined", () => { - const tool = { - name: "test-tool", - _meta: { ui: {} }, - }; - expect(isToolVisibilityAppOnly(tool)).toBe(false); - }); - - it("when _meta.ui is missing", () => { - const tool = { - name: "test-tool", - _meta: {}, - }; - expect(isToolVisibilityAppOnly(tool)).toBe(false); - }); - - it("when _meta is missing", () => { - const tool = { name: "test-tool" }; - expect(isToolVisibilityAppOnly(tool)).toBe(false); - }); - - it("when tool has resourceUri but no visibility", () => { - const tool = { - name: "test-tool", - _meta: { ui: { resourceUri: "ui://server/app.html" } }, - }; - expect(isToolVisibilityAppOnly(tool)).toBe(false); - }); - }); - - describe("addEventListener / removeEventListener", () => { - let app: App; - let bridge: AppBridge; - let appTransport: InMemoryTransport; - let bridgeTransport: InMemoryTransport; - - beforeEach(async () => { - [appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair(); - app = new App(testAppInfo, {}, { autoResize: false }); - bridge = new AppBridge( - createMockClient() as Client, - testHostInfo, - testHostCapabilities, - ); - await bridge.connect(bridgeTransport); - }); - - afterEach(async () => { - await appTransport.close(); - await bridgeTransport.close(); - }); - - it("App.addEventListener fires multiple listeners for the same event", async () => { - const a: unknown[] = []; - const b: unknown[] = []; - app.addEventListener("hostcontextchanged", (p) => a.push(p)); - app.addEventListener("hostcontextchanged", (p) => b.push(p)); - - await app.connect(appTransport); - bridge.setHostContext({ theme: "dark" }); - await flush(); - - expect(a).toEqual([{ theme: "dark" }]); - expect(b).toEqual([{ theme: "dark" }]); - }); - - it("App notification setters replace (DOM onclick model)", async () => { - const a: unknown[] = []; - const b: unknown[] = []; - const first = (p: unknown) => a.push(p); - app.ontoolinput = first; - expect(app.ontoolinput).toBe(first); - app.ontoolinput = (p) => b.push(p); - - await app.connect(appTransport); - await bridge.sendToolInput({ arguments: { x: 1 } }); - await flush(); - - // Second assignment replaced the first (like el.onclick) - expect(a).toEqual([]); - expect(b).toEqual([{ arguments: { x: 1 } }]); - }); - - it("App notification setter coexists with addEventListener", async () => { - const a: unknown[] = []; - const b: unknown[] = []; - app.ontoolinput = (p) => a.push(p); - app.addEventListener("toolinput", (p) => b.push(p)); - - await app.connect(appTransport); - await bridge.sendToolInput({ arguments: { x: 1 } }); - await flush(); - - // Both the on* handler and addEventListener listener fire - expect(a).toEqual([{ arguments: { x: 1 } }]); - expect(b).toEqual([{ arguments: { x: 1 } }]); - }); - - it("App notification getter returns the on* handler", () => { - expect(app.ontoolinput).toBeUndefined(); - const handler = () => {}; - app.ontoolinput = handler; - expect(app.ontoolinput).toBe(handler); - }); - - it("App notification setter can be cleared with undefined", async () => { - const a: unknown[] = []; - app.ontoolinput = (p) => a.push(p); - expect(app.ontoolinput).toBeDefined(); - app.ontoolinput = undefined; - - await app.connect(appTransport); - await bridge.sendToolInput({ arguments: { x: 1 } }); - await flush(); - - expect(a).toEqual([]); - expect(app.ontoolinput).toBeUndefined(); - }); - - it("App.removeEventListener stops a listener from firing", async () => { - const a: unknown[] = []; - const listener = (p: unknown) => a.push(p); - app.addEventListener("toolinput", listener); - app.removeEventListener("toolinput", listener); - - await app.connect(appTransport); - await bridge.sendToolInput({ arguments: {} }); - await flush(); - - expect(a).toEqual([]); - }); - - it("App merges hostcontext before listeners fire", async () => { - let seen: unknown; - app.addEventListener("hostcontextchanged", () => { - seen = app.getHostContext(); - }); - - await app.connect(appTransport); - bridge.setHostContext({ theme: "dark" }); - await flush(); - - expect(seen).toEqual({ theme: "dark" }); - }); - - it("AppBridge.addEventListener fires multiple listeners", async () => { - let a = 0; - let b = 0; - bridge.addEventListener("initialized", () => a++); - bridge.addEventListener("initialized", () => b++); - - await app.connect(appTransport); - - expect(a).toBe(1); - expect(b).toBe(1); - }); - - it("on* request setters have replace semantics (no throw)", () => { - app.onteardown = async () => ({}); - expect(() => { - app.onteardown = async () => ({}); - }).not.toThrow(); - }); - - it("on* request setters have getters", () => { - expect(app.onteardown).toBeUndefined(); - const handler = async () => ({}); - app.onteardown = handler; - expect(app.onteardown).toBe(handler); - }); - - it("direct setRequestHandler throws when called twice", () => { - const bridge2 = new AppBridge( - createMockClient() as Client, - testHostInfo, - testHostCapabilities, - ); - const params = z.object({}); - bridge2.setRequestHandler("test/method", { params }, () => ({})); - expect(() => { - bridge2.setRequestHandler("test/method", { params }, () => ({})); - }).toThrow(/already registered/); - }); - - it("direct setRequestHandler cannot silently replace an on* host handler", () => { - const bridge2 = new AppBridge( - createMockClient() as Client, - testHostInfo, - testHostCapabilities, - ); - bridge2.onopenlink = async () => ({}); - expect(() => { - bridge2.setRequestHandler( - "ui/open-link", - { params: z.object({}) }, - () => ({}), - ); - }).toThrow(/already registered/); - }); - - it("direct setNotificationHandler throws for event-mapped methods", () => { - const bridge2 = new AppBridge( - createMockClient() as Client, - testHostInfo, - testHostCapabilities, - ); - bridge2.onsizechange = () => {}; - expect(() => { - bridge2.setNotificationHandler( - "ui/notifications/size-changed", - { params: z.object({}) }, - () => {}, - ); - }).toThrow(/already registered/); - }); - - it("removeRequestHandler releases the method so an on* setter can re-register", () => { - const bridge2 = new AppBridge( - createMockClient() as Client, - testHostInfo, - testHostCapabilities, - ); - bridge2.removeRequestHandler("tools/call"); - expect(() => { - bridge2.setRequestHandler("tools/call", async () => ({ content: [] })); - }).not.toThrow(); - expect(() => { - bridge2.oncalltool = async () => ({ content: [] }); - }).not.toThrow(); - }); - - it("oncreatesamplingmessage has a getter, replace semantics, and a replace warning", () => { - const bridge2 = new AppBridge( - createMockClient() as Client, - testHostInfo, - testHostCapabilities, - ); - expect(bridge2.oncreatesamplingmessage).toBeUndefined(); - const first = async () => ({ - role: "assistant" as const, - content: { type: "text" as const, text: "" }, - model: "m", - }); - bridge2.oncreatesamplingmessage = first; - expect(bridge2.oncreatesamplingmessage).toBe(first); - - const warn = spyOn(console, "warn").mockImplementation(() => {}); - try { - expect(() => { - bridge2.oncreatesamplingmessage = first; - }).not.toThrow(); - expect(warn).toHaveBeenCalledWith( - expect.stringContaining("oncreatesamplingmessage handler replaced"), - ); - } finally { - warn.mockRestore(); - } - - bridge2.oncreatesamplingmessage = undefined; - expect(bridge2.oncreatesamplingmessage).toBeUndefined(); - }); - }); -}); - -describe("buildAllowAttribute", () => { - describe("returns empty string", () => { - it("when permissions is undefined", () => { - expect(buildAllowAttribute(undefined)).toBe(""); - }); - - it("when permissions object is empty", () => { - expect(buildAllowAttribute({})).toBe(""); - }); - }); - - describe("returns a single permission directive", () => { - it("when only camera is set", () => { - expect(buildAllowAttribute({ camera: {} })).toBe("camera"); - }); - - it("when only microphone is set", () => { - expect(buildAllowAttribute({ microphone: {} })).toBe("microphone"); - }); - - it("when only geolocation is set", () => { - expect(buildAllowAttribute({ geolocation: {} })).toBe("geolocation"); - }); - - it("when only clipboardWrite is set, maps to clipboard-write", () => { - expect(buildAllowAttribute({ clipboardWrite: {} })).toBe( - "clipboard-write", - ); - }); - }); - - describe("returns multiple directives joined with '; '", () => { - it("when camera and microphone are set", () => { - expect(buildAllowAttribute({ camera: {}, microphone: {} })).toBe( - "camera; microphone", - ); - }); - - it("when all permissions are set", () => { - expect( - buildAllowAttribute({ - camera: {}, - microphone: {}, - geolocation: {}, - clipboardWrite: {}, - }), - ).toBe("camera; microphone; geolocation; clipboard-write"); - }); - }); -}); diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 1ac4db4..2e96958 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -1,53 +1,50 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { + CallToolRequest, + CallToolRequestSchema, + CallToolResult, + CallToolResultSchema, + EmptyResult, + Implementation, + ListPromptsRequest, + ListPromptsRequestSchema, + ListPromptsResult, + ListPromptsResultSchema, + ListResourcesRequest, + ListResourcesRequestSchema, + ListResourcesResult, + ListResourcesResultSchema, + ListResourceTemplatesRequest, + ListResourceTemplatesRequestSchema, + ListResourceTemplatesResult, + ListResourceTemplatesResultSchema, + LoggingMessageNotification, + LoggingMessageNotificationSchema, + PingRequest, + PingRequestSchema, + PromptListChangedNotification, + PromptListChangedNotificationSchema, + ReadResourceRequest, + ReadResourceRequestSchema, + ReadResourceResult, + ReadResourceResultSchema, + ResourceListChangedNotification, + ResourceListChangedNotificationSchema, + Tool, + ToolListChangedNotification, + ToolListChangedNotificationSchema, +} from "@modelcontextprotocol/sdk/types.js"; import { Protocol, - type BaseContext, - type Client, - type ProtocolOptions, - type CallToolRequest, - type CallToolResult, - type CreateMessageRequest, - type CreateMessageResult, - type CreateMessageResultWithTools, - type EmptyResult, - type Implementation, - type JSONRPCRequest, - type ListPromptsRequest, - type ListPromptsResult, - type ListResourcesRequest, - type ListResourcesResult, - type ListResourceTemplatesRequest, - type ListResourceTemplatesResult, - type ListToolsRequest, - type LoggingMessageNotification, - type PingRequest, - type PromptListChangedNotification, - type ReadResourceRequest, - type ReadResourceResult, - type RequestOptions, - type ResourceListChangedNotification, - type Result, - type Tool, - type ToolListChangedNotification, - type Transport, -} from "@modelcontextprotocol/client"; -import { - EmptyResultSchema, - LoggingMessageNotificationSchema, -} from "@modelcontextprotocol/core"; -import { EventDispatcher, MethodRegistry } from "./events.js"; -import { - toModernArgs, - type LegacyNotificationHandlerSetter, - type LegacyRequestHandlerSetter, -} from "./legacy-handlers.js"; -import type { ZodLiteral, ZodObject, ZodType } from "zod/v4"; - -type MethodSchema = ZodObject<{ - method: ZodLiteral<string>; - params: ZodType; -}>; + ProtocolOptions, + RequestOptions, +} from "@modelcontextprotocol/sdk/shared/protocol.js"; import { + type AppNotification, + type AppRequest, + type AppResult, type McpUiSandboxResourceReadyNotification, type McpUiSizeChangedNotification, type McpUiToolCancelledNotification, @@ -66,39 +63,26 @@ import { McpUiInitializeRequest, McpUiInitializeRequestSchema, McpUiInitializeResult, - McpUiInitializeResultSchema, McpUiMessageRequest, McpUiMessageRequestSchema, McpUiMessageResult, - McpUiMessageResultSchema, McpUiOpenLinkRequest, McpUiOpenLinkRequestSchema, McpUiOpenLinkResult, - McpUiOpenLinkResultSchema, - McpUiDownloadFileRequest, - McpUiDownloadFileRequestSchema, - McpUiDownloadFileResult, - McpUiDownloadFileResultSchema, McpUiResourceTeardownRequest, McpUiResourceTeardownResultSchema, - McpUiRequestTeardownNotification, - McpUiRequestTeardownNotificationSchema, McpUiSandboxProxyReadyNotification, McpUiSandboxProxyReadyNotificationSchema, McpUiSizeChangedNotificationSchema, McpUiRequestDisplayModeRequest, McpUiRequestDisplayModeRequestSchema, McpUiRequestDisplayModeResult, - McpUiRequestDisplayModeResultSchema, McpUiResourcePermissions, - McpUiToolMeta, -} from "./types.js"; -export * from "./types.js"; -export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "./constants.js"; -import { RESOURCE_URI_META_KEY } from "./constants.js"; - -type UntypedHandlerSetter = (this: unknown, ...args: unknown[]) => void; -export { PostMessageTransport } from "./message-transport.js"; +} from "./types"; +export * from "./types"; +export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "./app"; +import { RESOURCE_URI_META_KEY } from "./app"; +export { PostMessageTransport } from "./message-transport"; /** * Extract UI resource URI from tool metadata. @@ -126,7 +110,7 @@ export { PostMessageTransport } from "./message-transport.js"; */ export function getToolUiResourceUri(tool: Partial<Tool>): string | undefined { // Try new nested format first: _meta.ui.resourceUri - const uiMeta = tool._meta?.ui as McpUiToolMeta | undefined; + const uiMeta = tool._meta?.ui as { resourceUri?: unknown } | undefined; let uri: unknown = uiMeta?.resourceUri; // Fall back to deprecated flat format: _meta["ui/resourceUri"] @@ -142,34 +126,6 @@ export function getToolUiResourceUri(tool: Partial<Tool>): string | undefined { return undefined; } -/** - * Check if a tool is visible to the model only. - * - * @param tool - Tool object with visibility metadata - * @returns True if the tool is visible to the model only, false otherwise - */ -export function isToolVisibilityModelOnly(tool: Partial<Tool>): boolean { - const uiMeta = tool._meta?.ui as McpUiToolMeta | undefined; - const visibility = uiMeta?.visibility; - if (!visibility) return false; - if (visibility.length === 1 && visibility[0] === "model") return true; - return false; -} - -/** - * Check if a tool is visible to the app only. - * - * @param tool - Tool object with visibility metadata - * @returns True if the tool is visible to the app only, false otherwise - */ -export function isToolVisibilityAppOnly(tool: Partial<Tool>): boolean { - const uiMeta = tool._meta?.ui as McpUiToolMeta | undefined; - const visibility = uiMeta?.visibility; - if (!visibility) return false; - if (visibility.length === 1 && visibility[0] === "app") return true; - return false; -} - /** * Build iframe `allow` attribute string from permissions. * @@ -201,14 +157,14 @@ export function buildAllowAttribute( } /** - * Options for configuring {@link AppBridge `AppBridge`} behavior. + * Options for configuring {@link AppBridge} behavior. * - * @property hostContext - Optional initial host context to provide to the view + * @property hostContext - Optional initial host context to provide to the Guest UI * - * @see `ProtocolOptions` from @modelcontextprotocol/client for available options - * @see {@link McpUiHostContext `McpUiHostContext`} for the hostContext structure + * @see `ProtocolOptions` from @modelcontextprotocol/sdk for available options + * @see {@link McpUiHostContext} for the hostContext structure */ -export type HostOptions = Omit<ProtocolOptions, "supportedProtocolVersions"> & { +export type HostOptions = ProtocolOptions & { hostContext?: McpUiHostContext; }; @@ -223,55 +179,46 @@ export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION]; /** * Extra metadata passed to request handlers. * - * This type represents the additional context (`BaseContext`) provided by the - * base MCP SDK `Protocol` when handling requests. + * This type represents the additional context provided by the `Protocol` class + * when handling requests, including abort signals and session information. + * It is extracted from the MCP SDK's request handler signature. * * @internal */ -type RequestHandlerExtra = BaseContext; +type RequestHandlerExtra = Parameters< + Parameters<AppBridge["setRequestHandler"]>[1] +>[1]; /** - * Maps DOM-style event names to their notification `params` types. + * Host-side bridge for communicating with a single Guest UI ({@link app!App}). * - * Used by {@link AppBridge `AppBridge`} to provide type-safe - * `addEventListener` / `removeEventListener` and singular `on*` handler - * support. - */ -export type AppBridgeEventMap = { - sizechange: McpUiSizeChangedNotification["params"]; - sandboxready: McpUiSandboxProxyReadyNotification["params"]; - initialized: McpUiInitializedNotification["params"]; - requestteardown: McpUiRequestTeardownNotification["params"]; - loggingmessage: LoggingMessageNotification["params"]; -}; - -/** - * Host-side bridge for communicating with a single View ({@link app!App `App`}). - * - * `AppBridge` extends the base MCP SDK's public `Protocol` class and acts as a - * proxy between the host application and a view running in an iframe. When a - * separate MCP client is provided to the constructor, it automatically forwards - * MCP server capabilities (tools, resources, prompts) to the view. It also - * handles the Apps initialization handshake. + * `AppBridge` extends the MCP SDK's `Protocol` class and acts as a proxy between + * the host application and a Guest UI running in an iframe. When an MCP client + * is provided to the constructor, it automatically forwards MCP server capabilities + * (tools, resources, prompts) to the Guest UI. It also handles the initialization + * handshake. * * ## Architecture * - * **View โ†” AppBridge โ†” Host โ†” MCP Server** + * **Guest UI โ†” AppBridge โ†” Host โ†” MCP Server** * - * The bridge proxies requests from the view to the MCP server and forwards + * The bridge proxies requests from the Guest UI to the MCP server and forwards * responses back. It also sends host-initiated notifications like tool input - * and results to the view. + * and results to the Guest UI. * * ## Lifecycle * * 1. **Create**: Instantiate `AppBridge` with MCP client and capabilities * 2. **Connect**: Call `connect()` with transport to establish communication - * 3. **Wait for init**: View sends initialize request, bridge responds - * 4. **Send data**: Call {@link sendToolInput `sendToolInput`}, {@link sendToolResult `sendToolResult`}, etc. - * 5. **Teardown**: Call {@link teardownResource `teardownResource`} before unmounting iframe + * 3. **Wait for init**: Guest UI sends initialize request, bridge responds + * 4. **Send data**: Call {@link sendToolInput}, {@link sendToolResult}, etc. + * 5. **Teardown**: Call {@link teardownResource} before unmounting iframe * * @example Basic usage - * ```ts source="./app-bridge.examples.ts#AppBridge_basicUsage" + * ```typescript + * import { AppBridge, PostMessageTransport } from '@modelcontextprotocol/ext-apps/app-bridge'; + * import { Client } from '@modelcontextprotocol/sdk/client/index.js'; + * * // Create MCP client for the server * const client = new Client({ * name: "MyHost", @@ -279,22 +226,22 @@ export type AppBridgeEventMap = { * }); * await client.connect(serverTransport); * - * // Create bridge for the View + * // Create bridge for the Guest UI * const bridge = new AppBridge( * client, * { name: "MyHost", version: "1.0.0" }, - * { openLinks: {}, serverTools: {}, logging: {} }, + * { openLinks: {}, serverTools: {}, logging: {} } * ); * * // Set up iframe and connect - * const iframe = document.getElementById("app") as HTMLIFrameElement; + * const iframe = document.getElementById('app') as HTMLIFrameElement; * const transport = new PostMessageTransport( * iframe.contentWindow!, * iframe.contentWindow!, * ); * * bridge.oninitialized = () => { - * console.log("View initialized"); + * console.log("Guest UI initialized"); * // Now safe to send tool input * bridge.sendToolInput({ arguments: { location: "NYC" } }); * }; @@ -302,342 +249,111 @@ export type AppBridgeEventMap = { * await bridge.connect(transport); * ``` */ -export class AppBridge extends Protocol<BaseContext> { +export class AppBridge extends Protocol< + AppRequest, + AppNotification, + AppResult +> { private _appCapabilities?: McpUiAppCapabilities; private _hostContext: McpUiHostContext = {}; private _appInfo?: Implementation; - private _initializedReceived = false; - private readonly _registeredEvents = new Set<keyof AppBridgeEventMap>(); - private readonly _events = new EventDispatcher<AppBridgeEventMap>(); - private readonly _methods = new MethodRegistry(); - - // โ”€โ”€ Handler registration with double-set protection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - // - // The base SDK `Protocol` silently replaces an existing handler. The four - // overrides below restore the v1 behaviour: a direct `setRequestHandler` / - // `setNotificationHandler` for a method that already has a handler throws, - // so a stray registration cannot silently replace a host's `on*` handler - // (for example the URL allow-listing in `onopenlink`) or disconnect - // `addEventListener` listeners. They are arrow-function class fields rather - // than prototype methods so that `Protocol`'s constructor โ€” which registers - // its own ping/cancelled/progress handlers before our fields initialize โ€” - // hits the base implementation and skips tracking. - - /** - * Registers a request handler. Throws if a handler for the same method has - * already been registered โ€” use the `on*` setter for replace semantics. - * - * @throws {Error} if a handler for this method is already registered. - */ - override setRequestHandler: LegacyRequestHandlerSetter< - Protocol<BaseContext>["setRequestHandler"] - > = (...args: unknown[]) => { - const [method, ...rest] = toModernArgs("request", args) ?? args; - this._methods.claim(method as string, "setRequestHandler"); - (super.setRequestHandler as unknown as UntypedHandlerSetter).call( - this, - method, - ...rest, - ); - }; - - /** - * Registers a notification handler. Throws if a handler for the same method - * has already been registered โ€” use the `on*` setter (replace semantics) or - * `addEventListener` (multi-listener) for mapped events. - * - * @throws {Error} if a handler for this method is already registered. - */ - override setNotificationHandler: LegacyNotificationHandlerSetter< - Protocol<BaseContext>["setNotificationHandler"] - > = (...args: unknown[]) => { - const [method, ...rest] = toModernArgs("notification", args) ?? args; - this._methods.claim(method as string, "setNotificationHandler"); - (super.setNotificationHandler as unknown as UntypedHandlerSetter).call( - this, - method, - ...rest, - ); - }; - - override removeRequestHandler: Protocol<BaseContext>["removeRequestHandler"] = - (method: string) => { - this._methods.release(method); - super.removeRequestHandler(method); - }; - - override removeNotificationHandler: Protocol<BaseContext>["removeNotificationHandler"] = - (method: string) => { - this._methods.release(method); - super.removeNotificationHandler(method); - }; - - /** - * Register a request handler with replace semantics, bypassing the - * double-set protection of {@link setRequestHandler `setRequestHandler`}. - * Used by the `on*` request-handler setters. - */ - protected replaceRequestHandler: Protocol<BaseContext>["setRequestHandler"] = - (method: string, ...rest: unknown[]) => { - this._methods.replace(method); - (super.setRequestHandler as unknown as UntypedHandlerSetter).call( - this, - method, - ...rest, - ); - }; - - /** - * The base MCP SDK calls this hook for every standard and custom request - * handler registration. AppBridge uses it to preserve the existing warning - * when a View calls host methods before `ui/notifications/initialized`, - * without adding overload-forwarding adapters around `setRequestHandler`. - * `ui/initialize` and `ping` are exempt because they are valid before the - * Apps-ready gate. This wraps registered callbacks, not JSON-RPC dispatch. - * - * @see {@link https://github.com/anthropics/claude-ai-mcp/issues/149 claude-ai-mcp#149} - */ - protected override _wrapHandler( - method: string, - handler: (request: JSONRPCRequest, context: BaseContext) => Promise<Result>, - ): (request: JSONRPCRequest, context: BaseContext) => Promise<Result> { - const wrapped = super._wrapHandler(method, handler); - if (method === "ui/initialize" || method === "ping") { - return wrapped; - } - return async (request, context) => { - if (!this._initializedReceived) { - console.warn( - `[ext-apps] AppBridge received '${method}' before ` + - `ui/notifications/initialized. The View is calling host ` + - `methods before completing the handshake; it should await ` + - `app.connect() first.`, - ); - } - return wrapped(request, context); - }; - } - - private readonly eventSchemas: { - [K in keyof AppBridgeEventMap]: MethodSchema; - } = { - sizechange: McpUiSizeChangedNotificationSchema, - sandboxready: McpUiSandboxProxyReadyNotificationSchema, - initialized: McpUiInitializedNotificationSchema, - requestteardown: McpUiRequestTeardownNotificationSchema, - loggingmessage: LoggingMessageNotificationSchema, - }; - - protected setEventHandler<K extends keyof AppBridgeEventMap>( - event: K, - handler: ((params: AppBridgeEventMap[K]) => void) | undefined, - ): void { - this._ensureEventSlot(event); - this._events.setHandler(event, handler); - } - - protected getEventHandler<K extends keyof AppBridgeEventMap>( - event: K, - ): ((params: AppBridgeEventMap[K]) => void) | undefined { - return this._events.getHandler(event); - } - - addEventListener<K extends keyof AppBridgeEventMap>( - event: K, - handler: (params: AppBridgeEventMap[K]) => void, - ): void { - this._ensureEventSlot(event); - this._events.addEventListener(event, handler); - } - - removeEventListener<K extends keyof AppBridgeEventMap>( - event: K, - handler: (params: AppBridgeEventMap[K]) => void, - ): void { - this._events.removeEventListener(event, handler); - } - - private _ensureEventSlot<K extends keyof AppBridgeEventMap>(event: K): void { - if (this._registeredEvents.has(event)) return; - const schema = this.eventSchemas[event]; - if (!schema) throw new Error(`Unknown event: ${String(event)}`); - - const dispatch = (params: AppBridgeEventMap[K]) => { - if (event === "initialized") this._initializedReceived = true; - this._events.dispatch(event, params); - }; - - if (event === "loggingmessage") { - this.setNotificationHandler("notifications/message", (notification) => { - dispatch(notification.params as AppBridgeEventMap[K]); - }); - } else { - this.setNotificationHandler( - schema.shape.method.value, - { params: schema.shape.params }, - (params) => dispatch(params as AppBridgeEventMap[K]), - ); - } - this._registeredEvents.add(event); - } - - protected warnIfRequestHandlerReplaced( - name: string, - previous: unknown, - next: unknown, - ): void { - if (previous && next) { - console.warn( - `[MCP Apps] ${name} handler replaced. ` + - `Previous handler will no longer be called.`, - ); - } - } /** * Create a new AppBridge instance. * * @param _client - MCP client connected to the server, or `null`. When provided, - * {@link connect `connect`} will automatically set up forwarding of MCP requests/notifications - * between the View and the server. When `null`, you must register handlers - * manually using the {@link oncalltool `oncalltool`}, {@link onlistresources `onlistresources`}, etc. setters. + * {@link connect} will automatically set up forwarding of MCP requests/notifications + * between the Guest UI and the server. When `null`, you must register handlers + * manually using the {@link oncalltool}, {@link onlistresources}, etc. setters. * @param _hostInfo - Host application identification (name and version) - * @param _hostCapabilities - Features and capabilities the host supports - * @param options - Configuration options (`ProtocolOptions` from the base MCP SDK plus `hostContext`) + * @param _capabilities - Features and capabilities the host supports + * @param options - Configuration options (inherited from Protocol) * * @example With MCP client (automatic forwarding) - * ```ts source="./app-bridge.examples.ts#AppBridge_constructor_withMcpClient" + * ```typescript * const bridge = new AppBridge( * mcpClient, * { name: "MyHost", version: "1.0.0" }, - * { openLinks: {}, serverTools: {}, logging: {} }, + * { openLinks: {}, serverTools: {}, logging: {} } * ); * ``` * * @example Without MCP client (manual handlers) - * ```ts source="./app-bridge.examples.ts#AppBridge_constructor_withoutMcpClient" + * ```typescript * const bridge = new AppBridge( * null, * { name: "MyHost", version: "1.0.0" }, - * { openLinks: {}, serverTools: {}, logging: {} }, + * { openLinks: {}, serverTools: {}, logging: {} } * ); - * bridge.oncalltool = async (params, extra) => { - * // Handle tool calls manually - * return { content: [] }; - * }; + * bridge.oncalltool = async (params, extra) => { ... }; * ``` */ constructor( private _client: Client | null, private _hostInfo: Implementation, - private _hostCapabilities: McpUiHostCapabilities, + private _capabilities: McpUiHostCapabilities, options?: HostOptions, ) { super(options); - this._methods.replace("notifications/cancelled"); - this._methods.replace("notifications/progress"); - - this._ensureEventSlot("initialized"); this._hostContext = options?.hostContext || {}; - this.replaceRequestHandler( - "ui/initialize", - { - params: McpUiInitializeRequestSchema.shape.params, - result: McpUiInitializeResultSchema, - }, - (params) => this._onAppsInitialize(params), + this.setRequestHandler(McpUiInitializeRequestSchema, (request) => + this._oninitialize(request), ); - this.replaceRequestHandler("ping", (request, extra) => { + this.setRequestHandler(PingRequestSchema, (request, extra) => { this.onping?.(request.params, extra); return {}; }); // Default handler for requestDisplayMode - returns current mode from host context. // Hosts can override this by setting bridge.onrequestdisplaymode = ... - this.replaceRequestHandler( - "ui/request-display-mode", - { - params: McpUiRequestDisplayModeRequestSchema.shape.params, - result: McpUiRequestDisplayModeResultSchema, - }, - () => { - const currentMode = this._hostContext.displayMode ?? "inline"; - return { mode: currentMode }; - }, - ); - } - - /** @internal */ - protected buildContext(ctx: BaseContext): BaseContext { - return ctx; + this.setRequestHandler(McpUiRequestDisplayModeRequestSchema, (request) => { + const currentMode = this._hostContext.displayMode ?? "inline"; + return { mode: currentMode }; + }); } /** - * Verify that the view supports the capability required for the given request method. - * @internal - */ - protected assertCapabilityForMethod(_method: string): void { - // Host-to-view requests are not capability-gated. - } - - /** - * Verify that a request handler is registered and supported for the given method. - * @internal - */ - protected assertRequestHandlerCapability(_method: string): void { - // Host handler registration is not capability-gated. - } - - /** - * Verify that the host supports the capability required for the given notification method. - * @internal - */ - protected assertNotificationCapability(_method: string): void { - // Host notifications are not capability-gated. - } - - /** - * Get the view's capabilities discovered during initialization. + * Get the Guest UI's capabilities discovered during initialization. * - * Returns the capabilities that the view advertised during its + * Returns the capabilities that the Guest UI advertised during its * initialization request. Returns `undefined` if called before * initialization completes. * - * @returns view capabilities, or `undefined` if not yet initialized + * @returns Guest UI capabilities, or `undefined` if not yet initialized * - * @example Check view capabilities after initialization - * ```ts source="./app-bridge.examples.ts#AppBridge_getAppCapabilities_checkAfterInit" + * @example Check Guest UI capabilities after initialization + * ```typescript * bridge.oninitialized = () => { * const caps = bridge.getAppCapabilities(); * if (caps?.tools) { - * console.log("View provides tools"); + * console.log("Guest UI provides tools"); * } * }; * ``` * - * @see {@link McpUiAppCapabilities `McpUiAppCapabilities`} for the capabilities structure + * @see {@link McpUiAppCapabilities} for the capabilities structure */ getAppCapabilities(): McpUiAppCapabilities | undefined { return this._appCapabilities; } /** - * Get the view's implementation info discovered during initialization. + * Get the Guest UI's implementation info discovered during initialization. * - * Returns the view's name and version as provided in its initialization + * Returns the Guest UI's name and version as provided in its initialization * request. Returns `undefined` if called before initialization completes. * - * @returns view implementation info, or `undefined` if not yet initialized + * @returns Guest UI implementation info, or `undefined` if not yet initialized * - * @example Log view information after initialization - * ```ts source="./app-bridge.examples.ts#AppBridge_getAppVersion_logAfterInit" + * @example Log Guest UI information after initialization + * ```typescript * bridge.oninitialized = () => { * const appInfo = bridge.getAppVersion(); * if (appInfo) { - * console.log(`View: ${appInfo.name} v${appInfo.version}`); + * console.log(`Guest UI: ${appInfo.name} v${appInfo.version}`); * } * }; * ``` @@ -647,10 +363,10 @@ export class AppBridge extends Protocol<BaseContext> { } /** - * Optional handler for ping requests from the view. + * Optional handler for ping requests from the Guest UI. * - * The View can send standard MCP `ping` requests to verify the connection - * is alive. The {@link AppBridge `AppBridge`} automatically responds with an empty object, but this + * The Guest UI can send standard MCP `ping` requests to verify the connection + * is alive. The {@link AppBridge} automatically responds with an empty object, but this * handler allows the host to observe or log ping activity. * * Unlike the other handlers which use setters, this is a direct property @@ -660,26 +376,26 @@ export class AppBridge extends Protocol<BaseContext> { * @param extra - Request metadata (abort signal, session info) * * @example - * ```ts source="./app-bridge.examples.ts#AppBridge_onping_handleRequest" + * ```typescript * bridge.onping = (params, extra) => { - * console.log("Received ping from view"); + * console.log("Received ping from Guest UI"); * }; * ``` */ onping?: (params: PingRequest["params"], extra: RequestHandlerExtra) => void; /** - * Register a handler for size change notifications from the view. + * Register a handler for size change notifications from the Guest UI. * - * The view sends `ui/notifications/size-changed` when its rendered content + * The Guest UI sends `ui/notifications/size-changed` when its rendered content * size changes, typically via `ResizeObserver`. Set this callback to dynamically - * adjust the iframe container dimensions based on the view's content. + * adjust the iframe container dimensions based on the Guest UI's content. * - * Note: This is for View โ†’ Host communication. To notify the View of - * host container dimension changes, use {@link setHostContext `setHostContext`}. + * Note: This is for Guest UI โ†’ Host communication. To notify the Guest UI of + * host container dimension changes, use {@link setHostContext}. * * @example - * ```ts source="./app-bridge.examples.ts#AppBridge_onsizechange_handleResize" + * ```typescript * bridge.onsizechange = ({ width, height }) => { * if (width != null) { * iframe.style.width = `${width}px`; @@ -690,21 +406,15 @@ export class AppBridge extends Protocol<BaseContext> { * }; * ``` * - * @see {@link McpUiSizeChangedNotification `McpUiSizeChangedNotification`} for the notification type - * @see {@link app!App.sendSizeChanged `App.sendSizeChanged`} - the View method that sends these notifications - * @deprecated Use {@link addEventListener `addEventListener("sizechange", handler)`} instead โ€” it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}. + * @see {@link McpUiSizeChangedNotification} for the notification type + * @see {@link app!App.sendSizeChanged} - the Guest UI method that sends these notifications */ - get onsizechange(): - | ((params: McpUiSizeChangedNotification["params"]) => void) - | undefined { - return this.getEventHandler("sizechange"); - } set onsizechange( - callback: - | ((params: McpUiSizeChangedNotification["params"]) => void) - | undefined, + callback: (params: McpUiSizeChangedNotification["params"]) => void, ) { - this.setEventHandler("sizechange", callback); + this.setNotificationHandler(McpUiSizeChangedNotificationSchema, (n) => + callback(n.params), + ); } /** @@ -715,14 +425,15 @@ export class AppBridge extends Protocol<BaseContext> { * `ui/notifications/sandbox-proxy-ready` after it loads and is ready to receive * HTML content. * - * When this fires, the host should call {@link sendSandboxResourceReady `sendSandboxResourceReady`} with + * When this fires, the host should call {@link sendSandboxResourceReady} with * the HTML content to load into the inner sandboxed iframe. * * @example * ```typescript * bridge.onsandboxready = async () => { * const resource = await mcpClient.request( - * { method: "resources/read", params: { uri: "ui://my-app" } } + * { method: "resources/read", params: { uri: "ui://my-app" } }, + * ReadResourceResultSchema * ); * * bridge.sendSandboxResourceReady({ @@ -733,58 +444,46 @@ export class AppBridge extends Protocol<BaseContext> { * ``` * * @internal - * @see {@link McpUiSandboxProxyReadyNotification `McpUiSandboxProxyReadyNotification`} for the notification type - * @see {@link sendSandboxResourceReady `sendSandboxResourceReady`} for sending content to the sandbox - * @deprecated Use {@link addEventListener `addEventListener("sandboxready", handler)`} instead โ€” it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}. + * @see {@link McpUiSandboxProxyReadyNotification} for the notification type + * @see {@link sendSandboxResourceReady} for sending content to the sandbox */ - get onsandboxready(): - | ((params: McpUiSandboxProxyReadyNotification["params"]) => void) - | undefined { - return this.getEventHandler("sandboxready"); - } set onsandboxready( - callback: - | ((params: McpUiSandboxProxyReadyNotification["params"]) => void) - | undefined, + callback: (params: McpUiSandboxProxyReadyNotification["params"]) => void, ) { - this.setEventHandler("sandboxready", callback); + this.setNotificationHandler(McpUiSandboxProxyReadyNotificationSchema, (n) => + callback(n.params), + ); } /** - * Called when the view completes initialization. + * Called when the Guest UI completes initialization. * - * Set this callback to be notified when the view has finished its + * Set this callback to be notified when the Guest UI has finished its * initialization handshake and is ready to receive tool input and other data. * * @example - * ```ts source="./app-bridge.examples.ts#AppBridge_oninitialized_sendToolInput" + * ```typescript * bridge.oninitialized = () => { - * console.log("View ready"); + * console.log("Guest UI ready"); * bridge.sendToolInput({ arguments: toolArgs }); * }; * ``` * - * @see {@link McpUiInitializedNotification `McpUiInitializedNotification`} for the notification type - * @see {@link sendToolInput `sendToolInput`} for sending tool arguments to the View - * @deprecated Use {@link addEventListener `addEventListener("initialized", handler)`} instead โ€” it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}. + * @see {@link McpUiInitializedNotification} for the notification type + * @see {@link sendToolInput} for sending tool arguments to the Guest UI */ - get oninitialized(): - | ((params?: McpUiInitializedNotification["params"]) => void) - | undefined { - return this.getEventHandler("initialized"); - } set oninitialized( - callback: - | ((params?: McpUiInitializedNotification["params"]) => void) - | undefined, + callback: (params: McpUiInitializedNotification["params"]) => void, ) { - this.setEventHandler("initialized", callback); + this.setNotificationHandler(McpUiInitializedNotificationSchema, (n) => + callback(n.params), + ); } /** - * Register a handler for message requests from the view. + * Register a handler for message requests from the Guest UI. * - * The view sends `ui/message` requests when it wants to add a message to + * The Guest UI sends `ui/message` requests when it wants to add a message to * the host's chat interface. This enables interactive apps to communicate with * the user through the conversation thread. * @@ -800,7 +499,7 @@ export class AppBridge extends Protocol<BaseContext> { * - Returns: `Promise<McpUiMessageResult>` with optional `isError` flag * * @example - * ```ts source="./app-bridge.examples.ts#AppBridge_onmessage_logMessage" + * ```typescript * bridge.onmessage = async ({ role, content }, extra) => { * try { * await chatManager.addMessage({ role, content, source: "app" }); @@ -812,43 +511,27 @@ export class AppBridge extends Protocol<BaseContext> { * }; * ``` * - * @see {@link McpUiMessageRequest `McpUiMessageRequest`} for the request type - * @see {@link McpUiMessageResult `McpUiMessageResult`} for the result type + * @see {@link McpUiMessageRequest} for the request type + * @see {@link McpUiMessageResult} for the result type */ - private _onmessage?: ( - params: McpUiMessageRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<McpUiMessageResult>; - get onmessage() { - return this._onmessage; - } set onmessage( - callback: - | (( - params: McpUiMessageRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<McpUiMessageResult>) - | undefined, + callback: ( + params: McpUiMessageRequest["params"], + extra: RequestHandlerExtra, + ) => Promise<McpUiMessageResult>, ) { - this.warnIfRequestHandlerReplaced("onmessage", this._onmessage, callback); - this._onmessage = callback; - this.replaceRequestHandler( - "ui/message", - { - params: McpUiMessageRequestSchema.shape.params, - result: McpUiMessageResultSchema, - }, - async (params, extra) => { - if (!this._onmessage) throw new Error("No onmessage handler set"); - return this._onmessage(params, extra); + this.setRequestHandler( + McpUiMessageRequestSchema, + async (request, extra) => { + return callback(request.params, extra); }, ); } /** - * Register a handler for external link requests from the view. + * Register a handler for external link requests from the Guest UI. * - * The view sends `ui/open-link` requests when it wants to open an external + * The Guest UI sends `ui/open-link` requests when it wants to open an external * URL in the host's default browser. The handler should validate the URL and * open it according to the host's security policy and user preferences. * @@ -864,7 +547,7 @@ export class AppBridge extends Protocol<BaseContext> { * - Returns: `Promise<McpUiOpenLinkResult>` with optional `isError` flag * * @example - * ```ts source="./app-bridge.examples.ts#AppBridge_onopenlink_handleRequest" + * ```typescript * bridge.onopenlink = async ({ url }, extra) => { * if (!isAllowedDomain(url)) { * console.warn("Blocked external link:", url); @@ -873,7 +556,7 @@ export class AppBridge extends Protocol<BaseContext> { * * const confirmed = await showDialog({ * message: `Open external link?\n${url}`, - * buttons: ["Open", "Cancel"], + * buttons: ["Open", "Cancel"] * }); * * if (confirmed) { @@ -885,157 +568,27 @@ export class AppBridge extends Protocol<BaseContext> { * }; * ``` * - * @see {@link McpUiOpenLinkRequest `McpUiOpenLinkRequest`} for the request type - * @see {@link McpUiOpenLinkResult `McpUiOpenLinkResult`} for the result type + * @see {@link McpUiOpenLinkRequest} for the request type + * @see {@link McpUiOpenLinkResult} for the result type */ - private _onopenlink?: ( - params: McpUiOpenLinkRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<McpUiOpenLinkResult>; - get onopenlink() { - return this._onopenlink; - } set onopenlink( - callback: - | (( - params: McpUiOpenLinkRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<McpUiOpenLinkResult>) - | undefined, + callback: ( + params: McpUiOpenLinkRequest["params"], + extra: RequestHandlerExtra, + ) => Promise<McpUiOpenLinkResult>, ) { - this.warnIfRequestHandlerReplaced("onopenlink", this._onopenlink, callback); - this._onopenlink = callback; - this.replaceRequestHandler( - "ui/open-link", - { - params: McpUiOpenLinkRequestSchema.shape.params, - result: McpUiOpenLinkResultSchema, - }, - async (params, extra) => { - if (!this._onopenlink) throw new Error("No onopenlink handler set"); - return this._onopenlink(params, extra); + this.setRequestHandler( + McpUiOpenLinkRequestSchema, + async (request, extra) => { + return callback(request.params, extra); }, ); } /** - * Register a handler for file download requests from the View. + * Register a handler for display mode change requests from the Guest UI. * - * The View sends `ui/download-file` requests when the user wants to - * download a file. The params contain an array of MCP resource content - * items โ€” either `EmbeddedResource` (inline data) or `ResourceLink` - * (URI the host can fetch). The host should show a confirmation dialog - * and then trigger the download. - * - * @param callback - Handler that receives download params and returns a result - * - `params.contents` - Array of `EmbeddedResource` or `ResourceLink` items - * - `extra` - Request metadata (abort signal, session info) - * - Returns: `Promise<McpUiDownloadFileResult>` with optional `isError` flag - * - * @example - * ```ts - * bridge.ondownloadfile = async ({ contents }, extra) => { - * for (const item of contents) { - * if (item.type === "resource") { - * // EmbeddedResource โ€” inline content - * const res = item.resource; - * const blob = res.blob - * ? new Blob([Uint8Array.from(atob(res.blob), c => c.charCodeAt(0))], { type: res.mimeType }) - * : new Blob([res.text ?? ""], { type: res.mimeType }); - * const url = URL.createObjectURL(blob); - * const link = document.createElement("a"); - * link.href = url; - * link.download = res.uri.split("/").pop() ?? "download"; - * link.click(); - * URL.revokeObjectURL(url); - * } else if (item.type === "resource_link") { - * // ResourceLink โ€” host fetches or opens directly - * window.open(item.uri, "_blank"); - * } - * } - * return {}; - * }; - * ``` - * - * @see {@link McpUiDownloadFileRequest `McpUiDownloadFileRequest`} for the request type - * @see {@link McpUiDownloadFileResult `McpUiDownloadFileResult`} for the result type - */ - private _ondownloadfile?: ( - params: McpUiDownloadFileRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<McpUiDownloadFileResult>; - get ondownloadfile() { - return this._ondownloadfile; - } - set ondownloadfile( - callback: - | (( - params: McpUiDownloadFileRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<McpUiDownloadFileResult>) - | undefined, - ) { - this.warnIfRequestHandlerReplaced( - "ondownloadfile", - this._ondownloadfile, - callback, - ); - this._ondownloadfile = callback; - this.replaceRequestHandler( - "ui/download-file", - { - params: McpUiDownloadFileRequestSchema.shape.params, - result: McpUiDownloadFileResultSchema, - }, - async (params, extra) => { - if (!this._ondownloadfile) - throw new Error("No ondownloadfile handler set"); - return this._ondownloadfile(params, extra); - }, - ); - } - - /** - * Register a handler for app-initiated teardown request notifications from the view. - * - * The view sends `ui/notifications/request-teardown` when it wants the host to tear it down. - * If the host decides to proceed, it should send - * `ui/resource-teardown` (via {@link teardownResource `teardownResource`}) to allow - * the view to perform gracefull termination, then unmount the iframe after the view responds. - * - * @example - * ```typescript - * bridge.onrequestteardown = async (params) => { - * console.log("App requested teardown"); - * // Initiate teardown to allow the app to persist unsaved state - * // Alternatively, the callback can early return to prevent teardown - * await bridge.teardownResource({}); - * // Now safe to unmount the iframe - * iframe.remove(); - * }; - * ``` - * - * @see {@link McpUiRequestTeardownNotification `McpUiRequestTeardownNotification`} for the notification type - * @see {@link teardownResource `teardownResource`} for initiating teardown - * @deprecated Use {@link addEventListener `addEventListener("requestteardown", handler)`} instead โ€” it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}. - */ - get onrequestteardown(): - | ((params: McpUiRequestTeardownNotification["params"]) => void) - | undefined { - return this.getEventHandler("requestteardown"); - } - set onrequestteardown( - callback: - | ((params: McpUiRequestTeardownNotification["params"]) => void) - | undefined, - ) { - this.setEventHandler("requestteardown", callback); - } - - /** - * Register a handler for display mode change requests from the view. - * - * The view sends `ui/request-display-mode` requests when it wants to change + * The Guest UI sends `ui/request-display-mode` requests when it wants to change * its display mode (e.g., from "inline" to "fullscreen"). The handler should * check if the requested mode is in `availableDisplayModes` from the host context, * update the display mode if supported, and return the actual mode that was set. @@ -1052,57 +605,41 @@ export class AppBridge extends Protocol<BaseContext> { * - Returns: `Promise<McpUiRequestDisplayModeResult>` with the actual mode set * * @example - * ```ts source="./app-bridge.examples.ts#AppBridge_onrequestdisplaymode_handleRequest" + * ```typescript + * let currentDisplayMode: McpUiDisplayMode = "inline"; + * * bridge.onrequestdisplaymode = async ({ mode }, extra) => { - * if (availableDisplayModes.includes(mode)) { + * const availableModes = hostContext.availableDisplayModes ?? ["inline"]; + * if (availableModes.includes(mode)) { * currentDisplayMode = mode; + * return { mode }; * } + * // Return current mode if requested mode not available * return { mode: currentDisplayMode }; * }; * ``` * - * @see {@link McpUiRequestDisplayModeRequest `McpUiRequestDisplayModeRequest`} for the request type - * @see {@link McpUiRequestDisplayModeResult `McpUiRequestDisplayModeResult`} for the result type + * @see {@link McpUiRequestDisplayModeRequest} for the request type + * @see {@link McpUiRequestDisplayModeResult} for the result type */ - private _onrequestdisplaymode?: ( - params: McpUiRequestDisplayModeRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<McpUiRequestDisplayModeResult>; - get onrequestdisplaymode() { - return this._onrequestdisplaymode; - } set onrequestdisplaymode( - callback: - | (( - params: McpUiRequestDisplayModeRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<McpUiRequestDisplayModeResult>) - | undefined, + callback: ( + params: McpUiRequestDisplayModeRequest["params"], + extra: RequestHandlerExtra, + ) => Promise<McpUiRequestDisplayModeResult>, ) { - this.warnIfRequestHandlerReplaced( - "onrequestdisplaymode", - this._onrequestdisplaymode, - callback, - ); - this._onrequestdisplaymode = callback; - this.replaceRequestHandler( - "ui/request-display-mode", - { - params: McpUiRequestDisplayModeRequestSchema.shape.params, - result: McpUiRequestDisplayModeResultSchema, - }, - async (params, extra) => { - if (!this._onrequestdisplaymode) - throw new Error("No onrequestdisplaymode handler set"); - return this._onrequestdisplaymode(params, extra); + this.setRequestHandler( + McpUiRequestDisplayModeRequestSchema, + async (request, extra) => { + return callback(request.params, extra); }, ); } /** - * Register a handler for logging messages from the view. + * Register a handler for logging messages from the Guest UI. * - * The view sends standard MCP `notifications/message` (logging) notifications + * The Guest UI sends standard MCP `notifications/message` (logging) notifications * to report debugging information, errors, warnings, and other telemetry to the * host. The host can display these in a console, log them to a file, or send * them to a monitoring service. @@ -1110,40 +647,38 @@ export class AppBridge extends Protocol<BaseContext> { * This uses the standard MCP logging notification format, not a UI-specific * message type. * - * The handler receives `LoggingMessageNotification["params"]`: - * - `level` โ€” "debug" | "info" | "notice" | "warning" | "error" | "critical" | "alert" | "emergency" - * - `logger` โ€” optional logger name/identifier - * - `data` โ€” log message and optional structured data + * @param callback - Handler that receives logging params + * - `params.level` - Log level: "debug" | "info" | "notice" | "warning" | "error" | "critical" | "alert" | "emergency" + * - `params.logger` - Optional logger name/identifier + * - `params.data` - Log message and optional structured data * * @example - * ```ts source="./app-bridge.examples.ts#AppBridge_onloggingmessage_handleLog" + * ```typescript * bridge.onloggingmessage = ({ level, logger, data }) => { + * const prefix = logger ? `[${logger}]` : "[Guest UI]"; * console[level === "error" ? "error" : "log"]( - * `[${logger ?? "View"}] ${level.toUpperCase()}:`, - * data, + * `${prefix} ${level.toUpperCase()}:`, + * data * ); * }; * ``` - * @deprecated Use {@link addEventListener `addEventListener("loggingmessage", handler)`} instead โ€” it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}. */ - get onloggingmessage(): - | ((params: LoggingMessageNotification["params"]) => void) - | undefined { - return this.getEventHandler("loggingmessage"); - } set onloggingmessage( - callback: - | ((params: LoggingMessageNotification["params"]) => void) - | undefined, + callback: (params: LoggingMessageNotification["params"]) => void, ) { - this.setEventHandler("loggingmessage", callback); + this.setNotificationHandler( + LoggingMessageNotificationSchema, + async (notification) => { + callback(notification.params); + }, + ); } /** - * Register a handler for model context updates from the view. + * Register a handler for model context updates from the Guest UI. * - * The view sends `ui/update-model-context` requests to update the Host's - * model context. Each request overwrites the previous context stored by the view. + * The Guest UI sends `ui/update-model-context` requests to update the Host's + * model context. Each request overwrites the previous context stored by the Guest UI. * Unlike logging messages, context updates are intended to be available to * the model in future turns. Unlike messages, context updates do not trigger follow-ups. * @@ -1152,58 +687,39 @@ export class AppBridge extends Protocol<BaseContext> { * update received. * * @example - * ```ts source="./app-bridge.examples.ts#AppBridge_onupdatemodelcontext_storeContext" - * bridge.onupdatemodelcontext = async ( - * { content, structuredContent }, - * extra, - * ) => { - * // Store the context snapshot for inclusion in the next model request - * modelContextManager.update({ content, structuredContent }); + * ```typescript + * bridge.onupdatemodelcontext = async ({ content, structuredContent }, extra) => { + * // Update the model context with the new snapshot + * modelContext = { + * type: "app_context", + * content, + * structuredContent, + * timestamp: Date.now() + * }; * return {}; * }; * ``` * - * @see {@link McpUiUpdateModelContextRequest `McpUiUpdateModelContextRequest`} for the request type + * @see {@link McpUiUpdateModelContextRequest} for the request type */ - private _onupdatemodelcontext?: ( - params: McpUiUpdateModelContextRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<EmptyResult>; - get onupdatemodelcontext() { - return this._onupdatemodelcontext; - } set onupdatemodelcontext( - callback: - | (( - params: McpUiUpdateModelContextRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<EmptyResult>) - | undefined, + callback: ( + params: McpUiUpdateModelContextRequest["params"], + extra: RequestHandlerExtra, + ) => Promise<EmptyResult>, ) { - this.warnIfRequestHandlerReplaced( - "onupdatemodelcontext", - this._onupdatemodelcontext, - callback, - ); - this._onupdatemodelcontext = callback; - this.replaceRequestHandler( - "ui/update-model-context", - { - params: McpUiUpdateModelContextRequestSchema.shape.params, - result: EmptyResultSchema, - }, - async (params, extra) => { - if (!this._onupdatemodelcontext) - throw new Error("No onupdatemodelcontext handler set"); - return this._onupdatemodelcontext(params, extra); + this.setRequestHandler( + McpUiUpdateModelContextRequestSchema, + async (request, extra) => { + return callback(request.params, extra); }, ); } /** - * Register a handler for tool call requests from the view. + * Register a handler for tool call requests from the Guest UI. * - * The view sends `tools/call` requests to execute MCP server tools. This + * The Guest UI sends `tools/call` requests to execute MCP server tools. This * handler allows the host to intercept and process these requests, typically * by forwarding them to the MCP server. * @@ -1213,109 +729,35 @@ export class AppBridge extends Protocol<BaseContext> { * - `extra` - Request metadata (abort signal, session info) * * @example - * ```ts source="./app-bridge.examples.ts#AppBridge_oncalltool_forwardToServer" - * bridge.oncalltool = async (params, extra) => { + * ```typescript + * bridge.oncalltool = async ({ name, arguments: args }, extra) => { * return mcpClient.request( - * { method: "tools/call", params }, - * { signal: extra.mcpReq.signal }, + * { method: "tools/call", params: { name, arguments: args } }, + * CallToolResultSchema, + * { signal: extra.signal } * ); * }; * ``` * - * @see `CallToolRequest` from @modelcontextprotocol/client for the request type - * @see `CallToolResult` from @modelcontextprotocol/client for the result type + * @see `CallToolRequest` from @modelcontextprotocol/sdk for the request type + * @see `CallToolResult` from @modelcontextprotocol/sdk for the result type */ - private _oncalltool?: ( - params: CallToolRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<CallToolResult>; - get oncalltool() { - return this._oncalltool; - } set oncalltool( - callback: - | (( - params: CallToolRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<CallToolResult>) - | undefined, + callback: ( + params: CallToolRequest["params"], + extra: RequestHandlerExtra, + ) => Promise<CallToolResult>, ) { - this.warnIfRequestHandlerReplaced("oncalltool", this._oncalltool, callback); - this._oncalltool = callback; - this.replaceRequestHandler("tools/call", async (request, extra) => { - if (!this._oncalltool) throw new Error("No oncalltool handler set"); - return this._oncalltool(request.params, extra); + this.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + return callback(request.params, extra); }); } /** - * Register a handler for LLM sampling requests from the view. + * Notify the Guest UI that the MCP server's tool list has changed. * - * The view sends standard MCP `sampling/createMessage` requests to obtain - * LLM completions via the host's model connection. The host has full - * discretion over which model to use and SHOULD apply rate limiting, - * cost controls, and user approval (human-in-the-loop) before sampling. - * - * Hosts that register this handler SHOULD advertise `sampling` (and - * `sampling.tools` if tool-calling is supported) in - * {@link McpUiHostCapabilities `McpUiHostCapabilities`}. - * - * @param callback - Handler that receives `CreateMessageRequest` params and - * returns a `CreateMessageResult` (or `CreateMessageResultWithTools` when - * `params.tools` was provided) - * - `params` - Standard MCP sampling params (messages, maxTokens, tools, etc.) - * - `extra` - Request metadata (abort signal, session info) - * - * @example Forward to your LLM provider - * ```ts source="./app-bridge.examples.ts#AppBridge_oncreatesamplingmessage_forwardToLlm" - * bridge.oncreatesamplingmessage = async (params, extra) => { - * // Apply rate limiting, user approval, cost controls here - * return await myLlmProvider.complete(params, { - * signal: extra.mcpReq.signal, - * }); - * }; - * ``` - * - * @see `CreateMessageRequest` from @modelcontextprotocol/client for the request type - * @see `CreateMessageResult` / `CreateMessageResultWithTools` from @modelcontextprotocol/client for result types - */ - private _oncreatesamplingmessage?: ( - params: CreateMessageRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<CreateMessageResult | CreateMessageResultWithTools>; - get oncreatesamplingmessage() { - return this._oncreatesamplingmessage; - } - set oncreatesamplingmessage( - callback: - | (( - params: CreateMessageRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<CreateMessageResult | CreateMessageResultWithTools>) - | undefined, - ) { - this.warnIfRequestHandlerReplaced( - "oncreatesamplingmessage", - this._oncreatesamplingmessage, - callback, - ); - this._oncreatesamplingmessage = callback; - this.replaceRequestHandler( - "sampling/createMessage", - async (request, extra) => { - if (!this._oncreatesamplingmessage) { - throw new Error("No oncreatesamplingmessage handler set"); - } - return this._oncreatesamplingmessage(request.params, extra); - }, - ); - } - - /** - * Notify the view that the MCP server's tool list has changed. - * - * The host sends `notifications/tools/list_changed` to the view when it - * receives this notification from the MCP server. This allows the view + * The host sends `notifications/tools/list_changed` to the Guest UI when it + * receives this notification from the MCP server. This allows the Guest UI * to refresh its tool cache or UI accordingly. * * @param params - Optional notification params (typically empty) @@ -1323,12 +765,12 @@ export class AppBridge extends Protocol<BaseContext> { * @example * ```typescript * // In your MCP client notification handler: - * mcpClient.setNotificationHandler("notifications/tools/list_changed", () => { + * mcpClient.setNotificationHandler(ToolListChangedNotificationSchema, () => { * bridge.sendToolListChanged(); * }); * ``` * - * @see `ToolListChangedNotification` from @modelcontextprotocol/client for the notification type + * @see `ToolListChangedNotification` from @modelcontextprotocol/sdk for the notification type */ sendToolListChanged(params: ToolListChangedNotification["params"] = {}) { return this.notification({ @@ -1338,9 +780,9 @@ export class AppBridge extends Protocol<BaseContext> { } /** - * Register a handler for list resources requests from the view. + * Register a handler for list resources requests from the Guest UI. * - * The view sends `resources/list` requests to enumerate available MCP + * The Guest UI sends `resources/list` requests to enumerate available MCP * resources. This handler allows the host to intercept and process these * requests, typically by forwarding them to the MCP server. * @@ -1350,50 +792,37 @@ export class AppBridge extends Protocol<BaseContext> { * - `extra` - Request metadata (abort signal, session info) * * @example - * ```ts source="./app-bridge.examples.ts#AppBridge_onlistresources_returnResources" + * ```typescript * bridge.onlistresources = async (params, extra) => { * return mcpClient.request( * { method: "resources/list", params }, - * { signal: extra.mcpReq.signal }, + * ListResourcesResultSchema, + * { signal: extra.signal } * ); * }; * ``` * - * @see `ListResourcesRequest` from @modelcontextprotocol/client for the request type - * @see `ListResourcesResult` from @modelcontextprotocol/client for the result type + * @see `ListResourcesRequest` from @modelcontextprotocol/sdk for the request type + * @see `ListResourcesResult` from @modelcontextprotocol/sdk for the result type */ - private _onlistresources?: ( - params: ListResourcesRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<ListResourcesResult>; - get onlistresources() { - return this._onlistresources; - } set onlistresources( - callback: - | (( - params: ListResourcesRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<ListResourcesResult>) - | undefined, + callback: ( + params: ListResourcesRequest["params"], + extra: RequestHandlerExtra, + ) => Promise<ListResourcesResult>, ) { - this.warnIfRequestHandlerReplaced( - "onlistresources", - this._onlistresources, - callback, + this.setRequestHandler( + ListResourcesRequestSchema, + async (request, extra) => { + return callback(request.params, extra); + }, ); - this._onlistresources = callback; - this.replaceRequestHandler("resources/list", async (request, extra) => { - if (!this._onlistresources) - throw new Error("No onlistresources handler set"); - return this._onlistresources(request.params, extra); - }); } /** - * Register a handler for list resource templates requests from the view. + * Register a handler for list resource templates requests from the Guest UI. * - * The view sends `resources/templates/list` requests to enumerate available + * The Guest UI sends `resources/templates/list` requests to enumerate available * MCP resource templates. This handler allows the host to intercept and process * these requests, typically by forwarding them to the MCP server. * @@ -1407,49 +836,33 @@ export class AppBridge extends Protocol<BaseContext> { * bridge.onlistresourcetemplates = async (params, extra) => { * return mcpClient.request( * { method: "resources/templates/list", params }, - * { signal: extra.mcpReq.signal } + * ListResourceTemplatesResultSchema, + * { signal: extra.signal } * ); * }; * ``` * - * @see `ListResourceTemplatesRequest` from @modelcontextprotocol/client for the request type - * @see `ListResourceTemplatesResult` from @modelcontextprotocol/client for the result type + * @see `ListResourceTemplatesRequest` from @modelcontextprotocol/sdk for the request type + * @see `ListResourceTemplatesResult` from @modelcontextprotocol/sdk for the result type */ - private _onlistresourcetemplates?: ( - params: ListResourceTemplatesRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<ListResourceTemplatesResult>; - get onlistresourcetemplates() { - return this._onlistresourcetemplates; - } set onlistresourcetemplates( - callback: - | (( - params: ListResourceTemplatesRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<ListResourceTemplatesResult>) - | undefined, + callback: ( + params: ListResourceTemplatesRequest["params"], + extra: RequestHandlerExtra, + ) => Promise<ListResourceTemplatesResult>, ) { - this.warnIfRequestHandlerReplaced( - "onlistresourcetemplates", - this._onlistresourcetemplates, - callback, - ); - this._onlistresourcetemplates = callback; - this.replaceRequestHandler( - "resources/templates/list", + this.setRequestHandler( + ListResourceTemplatesRequestSchema, async (request, extra) => { - if (!this._onlistresourcetemplates) - throw new Error("No onlistresourcetemplates handler set"); - return this._onlistresourcetemplates(request.params, extra); + return callback(request.params, extra); }, ); } /** - * Register a handler for read resource requests from the view. + * Register a handler for read resource requests from the Guest UI. * - * The view sends `resources/read` requests to retrieve the contents of an + * The Guest UI sends `resources/read` requests to retrieve the contents of an * MCP resource. This handler allows the host to intercept and process these * requests, typically by forwarding them to the MCP server. * @@ -1459,51 +872,38 @@ export class AppBridge extends Protocol<BaseContext> { * - `extra` - Request metadata (abort signal, session info) * * @example - * ```ts source="./app-bridge.examples.ts#AppBridge_onreadresource_returnResource" - * bridge.onreadresource = async (params, extra) => { + * ```typescript + * bridge.onreadresource = async ({ uri }, extra) => { * return mcpClient.request( - * { method: "resources/read", params }, - * { signal: extra.mcpReq.signal }, + * { method: "resources/read", params: { uri } }, + * ReadResourceResultSchema, + * { signal: extra.signal } * ); * }; * ``` * - * @see `ReadResourceRequest` from @modelcontextprotocol/client for the request type - * @see `ReadResourceResult` from @modelcontextprotocol/client for the result type + * @see `ReadResourceRequest` from @modelcontextprotocol/sdk for the request type + * @see `ReadResourceResult` from @modelcontextprotocol/sdk for the result type */ - private _onreadresource?: ( - params: ReadResourceRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<ReadResourceResult>; - get onreadresource() { - return this._onreadresource; - } set onreadresource( - callback: - | (( - params: ReadResourceRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<ReadResourceResult>) - | undefined, + callback: ( + params: ReadResourceRequest["params"], + extra: RequestHandlerExtra, + ) => Promise<ReadResourceResult>, ) { - this.warnIfRequestHandlerReplaced( - "onreadresource", - this._onreadresource, - callback, + this.setRequestHandler( + ReadResourceRequestSchema, + async (request, extra) => { + return callback(request.params, extra); + }, ); - this._onreadresource = callback; - this.replaceRequestHandler("resources/read", async (request, extra) => { - if (!this._onreadresource) - throw new Error("No onreadresource handler set"); - return this._onreadresource(request.params, extra); - }); } /** - * Notify the view that the MCP server's resource list has changed. + * Notify the Guest UI that the MCP server's resource list has changed. * - * The host sends `notifications/resources/list_changed` to the view when it - * receives this notification from the MCP server. This allows the view + * The host sends `notifications/resources/list_changed` to the Guest UI when it + * receives this notification from the MCP server. This allows the Guest UI * to refresh its resource cache or UI accordingly. * * @param params - Optional notification params (typically empty) @@ -1511,12 +911,12 @@ export class AppBridge extends Protocol<BaseContext> { * @example * ```typescript * // In your MCP client notification handler: - * mcpClient.setNotificationHandler("notifications/resources/list_changed", () => { + * mcpClient.setNotificationHandler(ResourceListChangedNotificationSchema, () => { * bridge.sendResourceListChanged(); * }); * ``` * - * @see `ResourceListChangedNotification` from @modelcontextprotocol/client for the notification type + * @see `ResourceListChangedNotification` from @modelcontextprotocol/sdk for the notification type */ sendResourceListChanged( params: ResourceListChangedNotification["params"] = {}, @@ -1528,9 +928,9 @@ export class AppBridge extends Protocol<BaseContext> { } /** - * Register a handler for list prompts requests from the view. + * Register a handler for list prompts requests from the Guest UI. * - * The view sends `prompts/list` requests to enumerate available MCP + * The Guest UI sends `prompts/list` requests to enumerate available MCP * prompts. This handler allows the host to intercept and process these * requests, typically by forwarding them to the MCP server. * @@ -1540,50 +940,35 @@ export class AppBridge extends Protocol<BaseContext> { * - `extra` - Request metadata (abort signal, session info) * * @example - * ```ts source="./app-bridge.examples.ts#AppBridge_onlistprompts_returnPrompts" + * ```typescript * bridge.onlistprompts = async (params, extra) => { * return mcpClient.request( * { method: "prompts/list", params }, - * { signal: extra.mcpReq.signal }, + * ListPromptsResultSchema, + * { signal: extra.signal } * ); * }; * ``` * - * @see `ListPromptsRequest` from @modelcontextprotocol/client for the request type - * @see `ListPromptsResult` from @modelcontextprotocol/client for the result type + * @see `ListPromptsRequest` from @modelcontextprotocol/sdk for the request type + * @see `ListPromptsResult` from @modelcontextprotocol/sdk for the result type */ - private _onlistprompts?: ( - params: ListPromptsRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<ListPromptsResult>; - get onlistprompts() { - return this._onlistprompts; - } set onlistprompts( - callback: - | (( - params: ListPromptsRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<ListPromptsResult>) - | undefined, + callback: ( + params: ListPromptsRequest["params"], + extra: RequestHandlerExtra, + ) => Promise<ListPromptsResult>, ) { - this.warnIfRequestHandlerReplaced( - "onlistprompts", - this._onlistprompts, - callback, - ); - this._onlistprompts = callback; - this.replaceRequestHandler("prompts/list", async (request, extra) => { - if (!this._onlistprompts) throw new Error("No onlistprompts handler set"); - return this._onlistprompts(request.params, extra); + this.setRequestHandler(ListPromptsRequestSchema, async (request, extra) => { + return callback(request.params, extra); }); } /** - * Notify the view that the MCP server's prompt list has changed. + * Notify the Guest UI that the MCP server's prompt list has changed. * - * The host sends `notifications/prompts/list_changed` to the view when it - * receives this notification from the MCP server. This allows the view + * The host sends `notifications/prompts/list_changed` to the Guest UI when it + * receives this notification from the MCP server. This allows the Guest UI * to refresh its prompt cache or UI accordingly. * * @param params - Optional notification params (typically empty) @@ -1591,12 +976,12 @@ export class AppBridge extends Protocol<BaseContext> { * @example * ```typescript * // In your MCP client notification handler: - * mcpClient.setNotificationHandler("notifications/prompts/list_changed", () => { + * mcpClient.setNotificationHandler(PromptListChangedNotificationSchema, () => { * bridge.sendPromptListChanged(); * }); * ``` * - * @see `PromptListChangedNotification` from @modelcontextprotocol/client for the notification type + * @see `PromptListChangedNotification` from @modelcontextprotocol/sdk for the notification type */ sendPromptListChanged(params: PromptListChangedNotification["params"] = {}) { return this.notification({ @@ -1605,37 +990,68 @@ export class AppBridge extends Protocol<BaseContext> { }); } + /** + * Verify that the guest supports the capability required for the given request method. + * @internal + */ + assertCapabilityForMethod(method: AppRequest["method"]): void { + // TODO + } + + /** + * Verify that a request handler is registered and supported for the given method. + * @internal + */ + assertRequestHandlerCapability(method: AppRequest["method"]): void { + // TODO + } + + /** + * Verify that the host supports the capability required for the given notification method. + * @internal + */ + assertNotificationCapability(method: AppNotification["method"]): void { + // TODO + } + + /** + * Verify that task creation is supported for the given request method. + * @internal + */ + protected assertTaskCapability(_method: string): void { + throw new Error("Tasks are not supported in MCP Apps"); + } + + /** + * Verify that task handler is supported for the given method. + * @internal + */ + protected assertTaskHandlerCapability(_method: string): void { + throw new Error("Task handlers are not supported in MCP Apps"); + } + /** * Get the host capabilities passed to the constructor. * * @returns Host capabilities object * - * @see {@link McpUiHostCapabilities `McpUiHostCapabilities`} for the capabilities structure + * @see {@link McpUiHostCapabilities} for the capabilities structure */ getCapabilities(): McpUiHostCapabilities { - return this._hostCapabilities; + return this._capabilities; } /** * Handle the ui/initialize request from the guest. * @internal */ - private async _onAppsInitialize( - params: McpUiInitializeRequest["params"], + private async _oninitialize( + request: McpUiInitializeRequest, ): Promise<McpUiInitializeResult> { - const requestedVersion = params.protocolVersion; + const requestedVersion = request.params.protocolVersion; - if (this._appInfo !== undefined) { - console.warn( - "[ext-apps] AppBridge received a second ui/initialize. The View may " + - "be double-mounting (e.g. React StrictMode in dev) without closing " + - "the previous App instance. Responding normally; the latest " + - "appInfo/appCapabilities replace the previous values.", - ); - } - - this._appCapabilities = params.appCapabilities; - this._appInfo = params.appInfo; + this._appCapabilities = request.params.appCapabilities; + this._appInfo = request.params.appInfo; const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes( requestedVersion, @@ -1652,14 +1068,14 @@ export class AppBridge extends Protocol<BaseContext> { } /** - * Update the host context and notify the view of changes. + * Update the host context and notify the Guest UI of changes. * * Compares fields present in the new context with the current context and sends a * `ui/notifications/host-context-changed` notification containing only fields * that have been added or modified. If no fields have changed, no notification is sent. * The new context fully replaces the internal state. * - * Common use cases include notifying the view when: + * Common use cases include notifying the Guest UI when: * - Theme changes (light/dark mode toggle) * - Viewport size changes (window resize) * - Display mode changes (inline/fullscreen) @@ -1668,20 +1084,20 @@ export class AppBridge extends Protocol<BaseContext> { * @param hostContext - The complete new host context state * * @example Update theme when user toggles dark mode - * ```ts source="./app-bridge.examples.ts#AppBridge_setHostContext_updateTheme" + * ```typescript * bridge.setHostContext({ theme: "dark" }); * ``` * * @example Update multiple context fields - * ```ts source="./app-bridge.examples.ts#AppBridge_setHostContext_updateMultiple" + * ```typescript * bridge.setHostContext({ * theme: "dark", - * containerDimensions: { maxHeight: 600, width: 800 }, + * containerDimensions: { maxHeight: 600, width: 800 } * }); * ``` * - * @see {@link McpUiHostContext `McpUiHostContext`} for the context structure - * @see {@link McpUiHostContextChangedNotification `McpUiHostContextChangedNotification`} for the notification type + * @see {@link McpUiHostContext} for the context structure + * @see {@link McpUiHostContextChangedNotification} for the notification type */ setHostContext(hostContext: McpUiHostContext) { const changes: McpUiHostContext = {}; @@ -1704,9 +1120,9 @@ export class AppBridge extends Protocol<BaseContext> { } /** - * Low-level method to notify the view of host context changes. + * Low-level method to notify the Guest UI of host context changes. * - * Most hosts should use {@link setHostContext `setHostContext`} instead, which automatically + * Most hosts should use {@link setHostContext} instead, which automatically * detects changes and calls this method with only the modified fields. * Use this directly only when you need fine-grained control over change detection. * @@ -1722,26 +1138,26 @@ export class AppBridge extends Protocol<BaseContext> { } /** - * Send complete tool arguments to the view. + * Send complete tool arguments to the Guest UI. * - * The host MUST send this notification after the View completes initialization - * (after {@link oninitialized `oninitialized`} callback fires) and complete tool arguments become available. - * This notification is sent exactly once and is required before {@link sendToolResult `sendToolResult`}. + * The host MUST send this notification after the Guest UI completes initialization + * (after {@link oninitialized} callback fires) and complete tool arguments become available. + * This notification is sent exactly once and is required before {@link sendToolResult}. * * @param params - Complete tool call arguments * * @example - * ```ts source="./app-bridge.examples.ts#AppBridge_sendToolInput_afterInit" + * ```typescript * bridge.oninitialized = () => { * bridge.sendToolInput({ - * arguments: { location: "New York", units: "metric" }, + * arguments: { location: "New York", units: "metric" } * }); * }; * ``` * - * @see {@link McpUiToolInputNotification `McpUiToolInputNotification`} for the notification type - * @see {@link oninitialized `oninitialized`} for the initialization callback - * @see {@link sendToolResult `sendToolResult`} for sending results after execution + * @see {@link McpUiToolInputNotification} for the notification type + * @see {@link oninitialized} for the initialization callback + * @see {@link sendToolResult} for sending results after execution */ sendToolInput(params: McpUiToolInputNotification["params"]) { return this.notification({ @@ -1751,33 +1167,31 @@ export class AppBridge extends Protocol<BaseContext> { } /** - * Send streaming partial tool arguments to the view. + * Send streaming partial tool arguments to the Guest UI. * * The host MAY send this notification zero or more times while tool arguments - * are being streamed, before {@link sendToolInput `sendToolInput`} is called with complete + * are being streamed, before {@link sendToolInput} is called with complete * arguments. This enables progressive rendering of tool arguments in the - * view. + * Guest UI. * - * The arguments represent best-effort recovery of incomplete JSON. views + * The arguments represent best-effort recovery of incomplete JSON. Guest UIs * SHOULD handle missing or changing fields gracefully between notifications. * * @param params - Partial tool call arguments (may be incomplete) * * @example Stream partial arguments as they arrive - * ```ts source="./app-bridge.examples.ts#AppBridge_sendToolInputPartial_streaming" + * ```typescript * // As streaming progresses... * bridge.sendToolInputPartial({ arguments: { loc: "N" } }); * bridge.sendToolInputPartial({ arguments: { location: "New" } }); * bridge.sendToolInputPartial({ arguments: { location: "New York" } }); * * // When complete, send final input - * bridge.sendToolInput({ - * arguments: { location: "New York", units: "metric" }, - * }); + * bridge.sendToolInput({ arguments: { location: "New York", units: "metric" } }); * ``` * - * @see {@link McpUiToolInputPartialNotification `McpUiToolInputPartialNotification`} for the notification type - * @see {@link sendToolInput `sendToolInput`} for sending complete arguments + * @see {@link McpUiToolInputPartialNotification} for the notification type + * @see {@link sendToolInput} for sending complete arguments */ sendToolInputPartial(params: McpUiToolInputPartialNotification["params"]) { return this.notification({ @@ -1787,26 +1201,28 @@ export class AppBridge extends Protocol<BaseContext> { } /** - * Send tool execution result to the view. + * Send tool execution result to the Guest UI. * * The host MUST send this notification when tool execution completes successfully, - * provided the view is still displayed. If the view was closed before execution + * provided the UI is still displayed. If the UI was closed before execution * completes, the host MAY skip this notification. This must be sent after - * {@link sendToolInput `sendToolInput`}. + * {@link sendToolInput}. * * @param params - Standard MCP tool execution result * * @example - * ```ts source="./app-bridge.examples.ts#AppBridge_sendToolResult_afterExecution" - * const result = await mcpClient.request({ - * method: "tools/call", - * params: { name: "get_weather", arguments: args }, - * }); + * ```typescript + * import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; + * + * const result = await mcpClient.request( + * { method: "tools/call", params: { name: "get_weather", arguments: args } }, + * CallToolResultSchema + * ); * bridge.sendToolResult(result); * ``` * - * @see {@link McpUiToolResultNotification `McpUiToolResultNotification`} for the notification type - * @see {@link sendToolInput `sendToolInput`} for sending tool arguments before results + * @see {@link McpUiToolResultNotification} for the notification type + * @see {@link sendToolInput} for sending tool arguments before results */ sendToolResult(params: McpUiToolResultNotification["params"]) { return this.notification({ @@ -1816,24 +1232,24 @@ export class AppBridge extends Protocol<BaseContext> { } /** - * Notify the view that tool execution was cancelled. + * Notify the Guest UI that tool execution was cancelled. * * The host MUST send this notification if tool execution was cancelled for any * reason, including user action, sampling error, classifier intervention, or - * any other interruption. This allows the view to update its state and + * any other interruption. This allows the Guest UI to update its state and * display appropriate feedback to the user. * * @param params - Cancellation details object * - `reason`: Human-readable explanation for why the tool was cancelled * * @example User-initiated cancellation - * ```ts source="./app-bridge.examples.ts#AppBridge_sendToolCancelled_userInitiated" + * ```typescript * // User clicked "Cancel" button * bridge.sendToolCancelled({ reason: "User cancelled the operation" }); * ``` * * @example System-level cancellation - * ```ts source="./app-bridge.examples.ts#AppBridge_sendToolCancelled_systemLevel" + * ```typescript * // Sampling error or timeout * bridge.sendToolCancelled({ reason: "Request timeout after 30 seconds" }); * @@ -1841,9 +1257,9 @@ export class AppBridge extends Protocol<BaseContext> { * bridge.sendToolCancelled({ reason: "Content policy violation detected" }); * ``` * - * @see {@link McpUiToolCancelledNotification `McpUiToolCancelledNotification`} for the notification type - * @see {@link sendToolResult `sendToolResult`} for sending successful results - * @see {@link sendToolInput `sendToolInput`} for sending tool arguments + * @see {@link McpUiToolCancelledNotification} for the notification type + * @see {@link sendToolResult} for sending successful results + * @see {@link sendToolInput} for sending tool arguments */ sendToolCancelled(params: McpUiToolCancelledNotification["params"]) { return this.notification({ @@ -1865,7 +1281,7 @@ export class AppBridge extends Protocol<BaseContext> { * - `sandbox`: Optional sandbox attribute value (e.g., "allow-scripts") * * @internal - * @see {@link onsandboxready `onsandboxready`} for handling the sandbox proxy ready notification + * @see {@link onsandboxready} for handling the sandbox proxy ready notification */ sendSandboxResourceReady( params: McpUiSandboxResourceReadyNotification["params"], @@ -1877,23 +1293,23 @@ export class AppBridge extends Protocol<BaseContext> { } /** - * Request graceful shutdown of the view. + * Request graceful shutdown of the Guest UI. * * The host MUST send this request before tearing down the UI resource (before - * unmounting the iframe). This gives the view an opportunity to save state, + * unmounting the iframe). This gives the Guest UI an opportunity to save state, * cancel pending operations, or show confirmation dialogs. * * The host SHOULD wait for the response before unmounting to prevent data loss. * * @param params - Empty params object * @param options - Request options (timeout, etc.) - * @returns Promise resolving when view confirms readiness for teardown + * @returns Promise resolving when Guest UI confirms readiness for teardown * * @example - * ```ts source="./app-bridge.examples.ts#AppBridge_teardownResource_gracefulShutdown" + * ```typescript * try { * await bridge.teardownResource({}); - * // View is ready, safe to unmount iframe + * // Guest UI is ready, safe to unmount iframe * iframe.remove(); * } catch (error) { * console.error("Teardown failed:", error); @@ -1914,53 +1330,27 @@ export class AppBridge extends Protocol<BaseContext> { ); } - /** @deprecated Use {@link teardownResource `teardownResource`} instead */ + /** @deprecated Use {@link teardownResource} instead */ sendResourceTeardown: AppBridge["teardownResource"] = this.teardownResource; /** - * Call a tool on the view. - * - * Sends a `tools/call` request to the view and returns the result. - * - * @param params - Tool call parameters (name and arguments) - * @param options - Request options (timeout, abort signal, etc.) - * @returns Promise resolving to the tool call result - */ - callTool(params: CallToolRequest["params"], options?: RequestOptions) { - return this.request({ method: "tools/call", params }, options); - } - - /** - * List tools available on the view. - * - * Sends a `tools/list` request to the view and returns the result. - * - * @param params - List tools parameters (may include cursor for pagination) - * @param options - Request options (timeout, abort signal, etc.) - * @returns Promise resolving to the list of tools - */ - listTools(params: ListToolsRequest["params"], options?: RequestOptions) { - return this.request({ method: "tools/list", params }, options); - } - - /** - * Connect to the view via transport and optionally set up message forwarding. + * Connect to the Guest UI via transport and optionally set up message forwarding. * * This method establishes the transport connection. If an MCP client was passed * to the constructor, it also automatically sets up request/notification forwarding - * based on the MCP server's capabilities, proxying the following to the view: + * based on the MCP server's capabilities, proxying the following to the Guest UI: * - Tools (tools/call, notifications/tools/list_changed) * - Resources (resources/list, resources/read, resources/templates/list, notifications/resources/list_changed) * - Prompts (prompts/list, notifications/prompts/list_changed) * * If no client was passed to the constructor, no automatic forwarding is set up - * and you must register handlers manually using the {@link oncalltool `oncalltool`}, {@link onlistresources `onlistresources`}, + * and you must register handlers manually using the {@link oncalltool}, {@link onlistresources}, * etc. setters. * - * After calling connect, wait for the {@link oninitialized `oninitialized`} callback before sending - * tool input and other data to the View. + * After calling connect, wait for the {@link oninitialized} callback before sending + * tool input and other data to the Guest UI. * - * @param transport - Transport layer (typically {@link PostMessageTransport `PostMessageTransport`}) + * @param transport - Transport layer (typically {@link PostMessageTransport}) * @returns Promise resolving when connection is established * * @throws {Error} If a client was passed but server capabilities are not available. @@ -1969,7 +1359,7 @@ export class AppBridge extends Protocol<BaseContext> { * before calling `bridge.connect()`. * * @example With MCP client (automatic forwarding) - * ```ts source="./app-bridge.examples.ts#AppBridge_connect_withMcpClient" + * ```typescript * const bridge = new AppBridge(mcpClient, hostInfo, capabilities); * const transport = new PostMessageTransport( * iframe.contentWindow!, @@ -1977,7 +1367,7 @@ export class AppBridge extends Protocol<BaseContext> { * ); * * bridge.oninitialized = () => { - * console.log("View ready"); + * console.log("Guest UI ready"); * bridge.sendToolInput({ arguments: toolArgs }); * }; * @@ -1985,28 +1375,21 @@ export class AppBridge extends Protocol<BaseContext> { * ``` * * @example Without MCP client (manual handlers) - * ```ts source="./app-bridge.examples.ts#AppBridge_connect_withoutMcpClient" + * ```typescript * const bridge = new AppBridge(null, hostInfo, capabilities); * * // Register handlers manually * bridge.oncalltool = async (params, extra) => { * // Custom tool call handling - * return { content: [] }; * }; * * await bridge.connect(transport); * ``` */ async connect(transport: Transport) { - if (this.transport) { - throw new Error( - "AppBridge is already connected. Call close() before connecting again.", - ); - } - this._initializedReceived = false; if (this._client) { // When a client was passed to the constructor, automatically forward - // MCP requests/notifications between the view and the server + // MCP requests/notifications between the Guest UI and the server const serverCapabilities = this._client.getServerCapabilities(); if (!serverCapabilities) { throw new Error("Client server capabilities not available"); @@ -2016,12 +1399,13 @@ export class AppBridge extends Protocol<BaseContext> { this.oncalltool = async (params, extra) => { return this._client!.request( { method: "tools/call", params }, - { signal: extra.mcpReq.signal }, + CallToolResultSchema, + { signal: extra.signal }, ); }; if (serverCapabilities.tools.listChanged) { this._client.setNotificationHandler( - "notifications/tools/list_changed", + ToolListChangedNotificationSchema, (n) => this.sendToolListChanged(n.params), ); } @@ -2030,24 +1414,27 @@ export class AppBridge extends Protocol<BaseContext> { this.onlistresources = async (params, extra) => { return this._client!.request( { method: "resources/list", params }, - { signal: extra.mcpReq.signal }, + ListResourcesResultSchema, + { signal: extra.signal }, ); }; this.onlistresourcetemplates = async (params, extra) => { return this._client!.request( { method: "resources/templates/list", params }, - { signal: extra.mcpReq.signal }, + ListResourceTemplatesResultSchema, + { signal: extra.signal }, ); }; this.onreadresource = async (params, extra) => { return this._client!.request( { method: "resources/read", params }, - { signal: extra.mcpReq.signal }, + ReadResourceResultSchema, + { signal: extra.signal }, ); }; if (serverCapabilities.resources.listChanged) { this._client.setNotificationHandler( - "notifications/resources/list_changed", + ResourceListChangedNotificationSchema, (n) => this.sendResourceListChanged(n.params), ); } @@ -2056,21 +1443,23 @@ export class AppBridge extends Protocol<BaseContext> { this.onlistprompts = async (params, extra) => { return this._client!.request( { method: "prompts/list", params }, - { signal: extra.mcpReq.signal }, + ListPromptsResultSchema, + { signal: extra.signal }, ); }; if (serverCapabilities.prompts.listChanged) { this._client.setNotificationHandler( - "notifications/prompts/list_changed", + PromptListChangedNotificationSchema, (n) => this.sendPromptListChanged(n.params), ); } } } - // Protocol.connect only wires the transport โ€” no MCP handshake happens on - // the iframe channel. The View drives ui/initialize; - // ui/notifications/initialized remains the public View-ready signal. + // MCP-UI specific handlers are registered by the host component + // after the proxy is created. The standard MCP initialization + // (via oninitialized callback set in constructor) handles the ready signal. + return super.connect(transport); } } diff --git a/src/app.examples.ts b/src/app.examples.ts deleted file mode 100644 index 7d475ef..0000000 --- a/src/app.examples.ts +++ /dev/null @@ -1,632 +0,0 @@ -/** - * Type-checked examples for {@link App `App`} and constants in {@link ./app.ts `app.ts`}. - * - * These examples are included in the API documentation via `@includeCode` tags. - * Each function's region markers define the code snippet that appears in the docs. - * - * @module - */ - -import type { SamplingMessage, Tool } from "@modelcontextprotocol/client"; -import type { McpServer, ToolCallback } from "@modelcontextprotocol/server"; -import { - App, - PostMessageTransport, - RESOURCE_URI_META_KEY, - McpUiToolMeta, -} from "./app.js"; -import { registerAppTool } from "./server/index.js"; - -/** - * Example: Modern format for registering tools with UI (recommended). - */ -function RESOURCE_URI_META_KEY_modernFormat( - server: McpServer, - handler: ToolCallback, -) { - //#region RESOURCE_URI_META_KEY_modernFormat - // Preferred: Use registerAppTool with nested ui.resourceUri - registerAppTool( - server, - "weather", - { - description: "Get weather forecast", - _meta: { - ui: { resourceUri: "ui://weather/forecast" }, - }, - }, - handler, - ); - //#endregion RESOURCE_URI_META_KEY_modernFormat -} - -/** - * Example: Legacy format using RESOURCE_URI_META_KEY (deprecated). - */ -function RESOURCE_URI_META_KEY_legacyFormat( - server: McpServer, - handler: ToolCallback, -) { - //#region RESOURCE_URI_META_KEY_legacyFormat - // Deprecated: Direct use of RESOURCE_URI_META_KEY - server.registerTool( - "weather", - { - description: "Get weather forecast", - _meta: { - [RESOURCE_URI_META_KEY]: "ui://weather/forecast", - }, - }, - handler, - ); - //#endregion RESOURCE_URI_META_KEY_legacyFormat -} - -/** - * Example: How hosts check for RESOURCE_URI_META_KEY metadata (must support both formats). - */ -function RESOURCE_URI_META_KEY_hostSide(tool: Tool) { - //#region RESOURCE_URI_META_KEY_hostSide - // Hosts should check both modern and legacy formats - const meta = tool._meta; - const uiMeta = meta?.ui as McpUiToolMeta | undefined; - const legacyUri = meta?.[RESOURCE_URI_META_KEY] as string | undefined; - const uiUri = uiMeta?.resourceUri ?? legacyUri; - if (typeof uiUri === "string" && uiUri.startsWith("ui://")) { - // Fetch the resource and display the UI - } - //#endregion RESOURCE_URI_META_KEY_hostSide -} - -/** - * Example: App constructor with appInfo, capabilities, and options. - */ -function App_constructor_basic() { - //#region App_constructor_basic - const app = new App( - { name: "MyApp", version: "1.0.0" }, - { tools: { listChanged: true } }, // capabilities - { autoResize: true }, // options - ); - //#endregion App_constructor_basic - return app; -} - -/** - * Example: Basic usage of the App class with PostMessageTransport. - */ -async function App_basicUsage() { - //#region App_basicUsage - const app = new App( - { name: "WeatherApp", version: "1.0.0" }, - {}, // capabilities - ); - - // Register handlers before connecting to ensure no notifications are missed - app.ontoolinput = (params) => { - console.log("Tool arguments:", params.arguments); - }; - - await app.connect(); - //#endregion App_basicUsage -} - -/** - * Example: Check host capabilities after connection. - */ -async function App_getHostCapabilities_checkAfterConnection(app: App) { - //#region App_getHostCapabilities_checkAfterConnection - await app.connect(); - if (app.getHostCapabilities()?.serverTools) { - console.log("Host supports server tool calls"); - } - //#endregion App_getHostCapabilities_checkAfterConnection -} - -/** - * Example: Log host information after connection. - */ -async function App_getHostVersion_logAfterConnection( - app: App, - transport: PostMessageTransport, -) { - //#region App_getHostVersion_logAfterConnection - await app.connect(transport); - const { name, version } = app.getHostVersion() ?? {}; - console.log(`Connected to ${name} v${version}`); - //#endregion App_getHostVersion_logAfterConnection -} - -/** - * Example: Access host context after connection. - */ -async function App_getHostContext_accessAfterConnection( - app: App, - transport: PostMessageTransport, -) { - //#region App_getHostContext_accessAfterConnection - await app.connect(transport); - const context = app.getHostContext(); - if (context?.theme === "dark") { - document.body.classList.add("dark-theme"); - } - if (context?.toolInfo) { - console.log("Tool:", context.toolInfo.tool.name); - } - //#endregion App_getHostContext_accessAfterConnection -} - -/** - * Example: Using the ontoolinput setter (simpler approach). - */ -async function App_ontoolinput_setter(app: App) { - //#region App_ontoolinput_setter - // Register before connecting to ensure no notifications are missed - app.ontoolinput = (params) => { - console.log("Tool:", params.arguments); - // Update your UI with the tool arguments - }; - await app.connect(); - //#endregion App_ontoolinput_setter -} - -/** - * Example: Progressive rendering of tool arguments using ontoolinputpartial. - */ -function App_ontoolinputpartial_progressiveRendering(app: App) { - //#region App_ontoolinputpartial_progressiveRendering - const codePreview = document.querySelector<HTMLPreElement>("#code-preview")!; - const canvas = document.querySelector<HTMLCanvasElement>("#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); - }; - //#endregion App_ontoolinputpartial_progressiveRendering -} - -// Stub for App_ontoolinputpartial_progressiveRendering example -declare function render(code: string): void; - -/** - * Example: Display tool execution results using ontoolresult. - */ -function App_ontoolresult_displayResults(app: App) { - //#region App_ontoolresult_displayResults - app.ontoolresult = (params) => { - if (params.isError) { - console.error("Tool execution failed:", params.content); - } else if (params.content) { - console.log("Tool output:", params.content); - } - }; - //#endregion App_ontoolresult_displayResults -} - -/** - * Example: Handle tool cancellation notifications. - */ -function App_ontoolcancelled_handleCancellation(app: App) { - //#region App_ontoolcancelled_handleCancellation - app.ontoolcancelled = (params) => { - console.log("Tool cancelled:", params.reason); - // Update your UI to show cancellation state - }; - //#endregion App_ontoolcancelled_handleCancellation -} - -/** - * Example: Respond to theme changes using onhostcontextchanged. - */ -function App_onhostcontextchanged_respondToTheme(app: App) { - //#region App_onhostcontextchanged_respondToTheme - app.onhostcontextchanged = (ctx) => { - if (ctx.theme === "dark") { - document.body.classList.add("dark-theme"); - } else { - document.body.classList.remove("dark-theme"); - } - }; - //#endregion App_onhostcontextchanged_respondToTheme -} - -/** - * Example: Respond to display mode changes using onhostcontextchanged. - */ -function App_onhostcontextchanged_respondToDisplayMode(app: App) { - //#region 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"; - } - }; - //#endregion App_onhostcontextchanged_respondToDisplayMode -} - -/** - * Example: Perform cleanup before teardown. - */ -function App_onteardown_performCleanup(app: App) { - //#region App_onteardown_performCleanup - app.onteardown = async () => { - await saveState(); - closeConnections(); - console.log("App ready for teardown"); - return {}; - }; - //#endregion App_onteardown_performCleanup -} - -// Stubs for example -declare function saveState(): Promise<void>; -declare function closeConnections(): void; - -/** - * Example: Handle tool calls from the host. - */ -function App_oncalltool_handleFromHost(app: App) { - //#region App_oncalltool_handleFromHost - app.oncalltool = async (params, extra) => { - if (params.name === "greet") { - const name = params.arguments?.name ?? "World"; - return { content: [{ type: "text", text: `Hello, ${name}!` }] }; - } - throw new Error(`Unknown tool: ${params.name}`); - }; - //#endregion App_oncalltool_handleFromHost -} - -/** - * Example: Return available tools from the onlisttools handler. - */ -function App_onlisttools_returnTools(app: App) { - //#region App_onlisttools_returnTools - app.onlisttools = async (params, extra) => { - return { - tools: [ - { - name: "greet", - description: "Greet the user", - inputSchema: { type: "object" as const }, - }, - { - name: "calculate", - description: "Perform a calculation", - inputSchema: { type: "object" as const }, - }, - { - name: "format", - description: "Format text", - inputSchema: { type: "object" as const }, - }, - ], - }; - }; - //#endregion App_onlisttools_returnTools -} - -/** - * Example: Fetch updated weather data using callServerTool. - */ -async function App_callServerTool_fetchWeather(app: App) { - //#region App_callServerTool_fetchWeather - try { - const result = await app.callServerTool({ - name: "get_weather", - arguments: { location: "Tokyo" }, - }); - if (result.isError) { - console.error("Tool returned error:", result.content); - } else { - console.log(result.content); - } - } catch (error) { - console.error("Tool call failed:", error); - } - //#endregion App_callServerTool_fetchWeather -} - -/** - * Example: Simple LLM completion via host sampling. - */ -async function App_createSamplingMessage_simple(app: App) { - //#region App_createSamplingMessage_simple - const result = await app.createSamplingMessage({ - messages: [ - { - role: "user", - content: { type: "text", text: "Summarize this in one line." }, - }, - ], - maxTokens: 100, - }); - console.log(result.content); - //#endregion App_createSamplingMessage_simple -} - -/** - * Example: Agentic loop with tools (requires host sampling.tools capability). - */ -async function App_createSamplingMessage_withTools( - app: App, - messages: SamplingMessage[], -) { - //#region App_createSamplingMessage_withTools - if (!app.getHostCapabilities()?.sampling?.tools) return; - - const result = await app.createSamplingMessage({ - messages, - maxTokens: 1024, - tools: [ - { - name: "get_weather", - description: "Get the current weather", - inputSchema: { - type: "object", - properties: { city: { type: "string" } }, - }, - }, - ], - }); - if (result.stopReason === "toolUse") { - // result.content may be an array containing tool_use blocks - } - //#endregion App_createSamplingMessage_withTools -} - -/** - * Example: Read a video resource and play it. - */ -async function App_readServerResource_playVideo( - app: App, - videoElement: HTMLVideoElement, -) { - //#region App_readServerResource_playVideo - try { - const result = await app.readServerResource({ - uri: "videos://bunny-1mb", - }); - const content = result.contents[0]; - if (content && "blob" in content) { - const binary = Uint8Array.from(atob(content.blob), (c) => - c.charCodeAt(0), - ); - const url = URL.createObjectURL( - new Blob([binary], { type: content.mimeType || "video/mp4" }), - ); - videoElement.src = url; - videoElement.play(); - } - } catch (error) { - console.error("Failed to read resource:", error); - } - //#endregion App_readServerResource_playVideo -} - -/** - * Example: Discover available videos and build a picker UI. - */ -async function App_listServerResources_buildPicker( - app: App, - selectElement: HTMLSelectElement, -) { - //#region App_listServerResources_buildPicker - try { - const result = await app.listServerResources(); - const videoResources = result.resources.filter((r) => - r.mimeType?.startsWith("video/"), - ); - videoResources.forEach((resource) => { - const option = document.createElement("option"); - option.value = resource.uri; - option.textContent = resource.description || resource.name; - selectElement.appendChild(option); - }); - } catch (error) { - console.error("Failed to list resources:", error); - } - //#endregion App_listServerResources_buildPicker -} - -/** - * Example: Send a text message from user interaction. - */ -async function App_sendMessage_textFromInteraction(app: App) { - //#region App_sendMessage_textFromInteraction - try { - const result = await app.sendMessage({ - role: "user", - content: [{ type: "text", text: "Show me details for item #42" }], - }); - if (result.isError) { - console.error("Host rejected the message"); - // Handle rejection appropriately for your app - } - } catch (error) { - console.error("Failed to send message:", error); - // Handle transport/protocol error - } - //#endregion App_sendMessage_textFromInteraction -} - -/** - * Example: Send follow-up message after offloading large data to model context. - */ -async function App_sendMessage_withLargeContext( - app: App, - fullTranscript: string, - speakerNames: string[], -) { - //#region 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" }], - }); - //#endregion App_sendMessage_withLargeContext -} - -/** - * Example: Log app state for debugging. - */ -function App_sendLog_debugState(app: App) { - //#region App_sendLog_debugState - app.sendLog({ - level: "info", - data: "Weather data refreshed", - logger: "WeatherApp", - }); - //#endregion App_sendLog_debugState -} - -/** - * Example: Update model context with current app state. - */ -async function App_updateModelContext_appState( - app: App, - itemList: string[], - totalCost: string, - currency: string, -) { - //#region 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 }], - }); - //#endregion App_updateModelContext_appState -} - -/** - * Example: Report runtime error to model. - */ -async function App_updateModelContext_reportError(app: App) { - //#region 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", - }, - ], - }); - } - //#endregion App_updateModelContext_reportError -} - -/** - * Example: Open documentation link. - */ -async function App_openLink_documentation(app: App) { - //#region App_openLink_documentation - const { isError } = await app.openLink({ url: "https://docs.example.com" }); - if (isError) { - // Host denied the request (e.g., blocked domain, user cancelled) - // Optionally show fallback: display URL for manual copy - console.warn("Link request denied"); - } - //#endregion App_openLink_documentation -} - -/** - * Example: Toggle between inline and fullscreen display modes. - */ -async function App_requestDisplayMode_toggle(app: App) { - //#region 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"); - } - //#endregion App_requestDisplayMode_toggle -} - -/** - * Example: Manually notify host of size change. - */ -function App_sendSizeChanged_manual(app: App) { - //#region App_sendSizeChanged_manual - app.sendSizeChanged({ - width: 400, - height: 600, - }); - //#endregion App_sendSizeChanged_manual -} - -/** - * Example: Manual setup for custom scenarios (setupSizeChangedNotifications). - */ -async function App_setupAutoResize_manual(transport: PostMessageTransport) { - //#region App_setupAutoResize_manual - const app = new App( - { name: "MyApp", version: "1.0.0" }, - {}, - { autoResize: false }, - ); - await app.connect(transport); - - // Later, enable auto-resize manually - const cleanup = app.setupSizeChangedNotifications(); - - // Clean up when done - cleanup(); - //#endregion App_setupAutoResize_manual -} - -/** - * Example: Connect with PostMessageTransport. - */ -async function App_connect_withPostMessageTransport() { - //#region App_connect_withPostMessageTransport - const app = new App({ name: "MyApp", version: "1.0.0" }, {}); - - try { - await app.connect(new PostMessageTransport(window.parent, window.parent)); - console.log("Connected successfully!"); - } catch (error) { - console.error("Failed to connect:", error); - } - //#endregion App_connect_withPostMessageTransport -} diff --git a/src/app.test.ts b/src/app.test.ts deleted file mode 100644 index 8420e81..0000000 --- a/src/app.test.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { afterEach, describe, expect, it } from "bun:test"; -import { - InMemoryTransport, - Server, - type JSONRPCMessage, -} from "@modelcontextprotocol/server"; -import { z } from "zod/v4"; - -import { App } from "./app.js"; -import { - LATEST_PROTOCOL_VERSION, - McpUiInitializeRequestSchema, - McpUiInitializeResultSchema, - McpUiInitializedNotificationSchema, -} from "./types.js"; - -type ConnectedPair = { - app: App; - server: Server; - sentByApp: JSONRPCMessage[]; - appsInitialized: Promise<void>; -}; - -async function connectPair( - app = new App( - { name: "view-app", version: "1.0.0" }, - { tools: { listChanged: true } }, - { autoResize: false }, - ), -): Promise<ConnectedPair> { - const server = new Server( - { name: "inner-mcp-server", version: "0.0.0" }, - { - capabilities: { resources: {}, tools: {} }, - }, - ); - const [appTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const sentByApp: JSONRPCMessage[] = []; - const send = appTransport.send.bind(appTransport); - appTransport.send = async (message, options) => { - sentByApp.push(message); - await send(message, options); - }; - - let resolveAppsInitialized!: () => void; - const appsInitialized = new Promise<void>((resolve) => { - resolveAppsInitialized = resolve; - }); - - server.setRequestHandler( - "ui/initialize", - { - params: McpUiInitializeRequestSchema.shape.params, - result: McpUiInitializeResultSchema, - }, - (params) => { - expect(params.protocolVersion).toBe(LATEST_PROTOCOL_VERSION); - return { - protocolVersion: LATEST_PROTOCOL_VERSION, - hostCapabilities: { openLinks: {} }, - hostInfo: { name: "apps-host", version: "2.0.0" }, - hostContext: { theme: "dark" as const, locale: "en-US" }, - }; - }, - ); - server.setNotificationHandler( - "ui/notifications/initialized", - { params: McpUiInitializedNotificationSchema.shape.params }, - () => resolveAppsInitialized(), - ); - server.setRequestHandler("tools/call", (request) => ({ - content: [{ type: "text", text: `called ${request.params.name}` }], - })); - server.setRequestHandler("resources/read", (request) => ({ - contents: [{ uri: request.params.uri, text: "resource body" }], - })); - server.setRequestHandler("resources/list", () => ({ - resources: [{ uri: "test://resource", name: "Test resource" }], - })); - server.setRequestHandler("sampling/createMessage", () => ({ - model: "test-model", - role: "assistant", - content: { type: "text", text: "sampled" }, - })); - - await server.connect(serverTransport); - await app.connect(appTransport); - await appsInitialized; - - return { app, server, sentByApp, appsInitialized }; -} - -const connected: Array<Pick<ConnectedPair, "app" | "server">> = []; - -afterEach(async () => { - await Promise.all( - connected - .splice(0) - .flatMap(({ app, server }) => [app.close(), server.close()]), - ); -}); - -describe("App base MCP SDK v2 Protocol migration", () => { - it("performs only the Apps handshake โ€” no MCP initialize on the wire", async () => { - const pair = await connectPair(); - connected.push(pair); - - const lifecycleMethods = pair.sentByApp - .filter( - (message): message is JSONRPCMessage & { method: string } => - "method" in message, - ) - .map((message) => message.method) - .filter((method) => - [ - "initialize", - "notifications/initialized", - "ui/initialize", - "ui/notifications/initialized", - ].includes(method), - ); - - // The filter above includes the core MCP lifecycle methods, so this - // equality also proves no `initialize`/`notifications/initialized` was sent - // โ€” the wire contract deployed v1 hosts depend on. - expect(lifecycleMethods).toEqual([ - "ui/initialize", - "ui/notifications/initialized", - ]); - }); - - it("uses ui/initialize as the authority for Apps host state", async () => { - const pair = await connectPair(); - connected.push(pair); - - expect(pair.app.getHostVersion()).toEqual({ - name: "apps-host", - version: "2.0.0", - }); - expect(pair.app.getHostCapabilities()).toEqual({ openLinks: {} }); - expect(pair.app.getHostContext()).toEqual({ - theme: "dark", - locale: "en-US", - }); - }); - - it("preserves registered App tools with the base MCP SDK v2 Client handler context", async () => { - const pair = await connectPair(); - connected.push(pair); - let receivedSignal: AbortSignal | undefined; - - pair.app.registerTool( - "greet", - { description: "Greets a person" }, - async (extra) => { - receivedSignal = extra.mcpReq.signal; - return { content: [{ type: "text", text: "hello" }] }; - }, - ); - - const listed = await pair.server.request({ - method: "tools/list", - params: {}, - }); - const result = await pair.server.request({ - method: "tools/call", - params: { name: "greet", arguments: {} }, - }); - - expect(listed.tools.map((tool) => tool.name)).toEqual(["greet"]); - expect(result.content).toEqual([{ type: "text", text: "hello" }]); - expect(receivedSignal).toBeInstanceOf(AbortSignal); - }); - - it("uses base MCP SDK v2 method-keyed sends for standard MCP requests", async () => { - const pair = await connectPair(); - connected.push(pair); - - const toolResult = await pair.app.callServerTool({ - name: "refresh", - arguments: {}, - }); - const resource = await pair.app.readServerResource({ - uri: "test://resource", - }); - const resources = await pair.app.listServerResources(); - const sample = await pair.app.createSamplingMessage({ - messages: [{ role: "user", content: { type: "text", text: "hello" } }], - maxTokens: 20, - }); - - expect(toolResult.content).toEqual([ - { type: "text", text: "called refresh" }, - ]); - expect(resource.contents[0]).toEqual({ - uri: "test://resource", - text: "resource body", - }); - expect(resources.resources.map(({ uri }) => uri)).toEqual([ - "test://resource", - ]); - expect(sample.content).toEqual({ type: "text", text: "sampled" }); - }); - - it("preserves reconnect behavior after close", async () => { - const first = await connectPair(); - await first.app.close(); - await first.server.close(); - - const second = await connectPair(first.app); - connected.push(second); - - expect(second.app.getHostVersion()).toEqual({ - name: "apps-host", - version: "2.0.0", - }); - }); - - it("preserves auto-resize setup after Apps initialization", async () => { - const app = new App( - { name: "view-app", version: "1.0.0" }, - {}, - { autoResize: true }, - ); - let setupCalls = 0; - app.setupSizeChangedNotifications = () => { - setupCalls += 1; - return () => {}; - }; - - const pair = await connectPair(app); - connected.push(pair); - - expect(setupCalls).toBe(1); - }); - - it("preserves strict late-handler timing errors", async () => { - const app = new App( - { name: "view-app", version: "1.0.0" }, - {}, - { autoResize: false, strict: true }, - ); - const pair = await connectPair(app); - connected.push(pair); - - expect(() => { - app.ontoolinput = () => {}; - }).toThrow(/handler registered after connect/); - }); - - it("direct setRequestHandler throws when a handler is already registered", async () => { - const pair = await connectPair(); - connected.push(pair); - const params = z.object({}); - let handledBy = 0; - - pair.app.setRequestHandler("test/method", { params }, () => { - handledBy = 1; - return {}; - }); - expect(() => { - pair.app.setRequestHandler("test/method", { params }, () => { - handledBy = 2; - return {}; - }); - }).toThrow(/already registered/); - - await pair.server.request( - { method: "test/method", params: {} }, - z.object({}), - ); - expect(handledBy).toBe(1); - }); - - it("direct setRequestHandler throws for methods owned by the App itself", async () => { - const pair = await connectPair(); - connected.push(pair); - expect(() => { - pair.app.setRequestHandler("ping", () => ({})); - }).toThrow(/already registered/); - }); - - it("removeRequestHandler releases the method for re-registration", async () => { - const pair = await connectPair(); - connected.push(pair); - const params = z.object({}); - let handledBy = 0; - - pair.app.setRequestHandler("test/method", { params }, () => { - handledBy = 1; - return {}; - }); - pair.app.removeRequestHandler("test/method"); - pair.app.setRequestHandler("test/method", { params }, () => { - handledBy = 2; - return {}; - }); - - await pair.server.request( - { method: "test/method", params: {} }, - z.object({}), - ); - expect(handledBy).toBe(2); - }); - - it("direct setNotificationHandler throws when a handler is already registered", async () => { - const pair = await connectPair(); - connected.push(pair); - const calls: number[] = []; - const params = z.object({}); - - pair.app.setNotificationHandler("test/notification", { params }, () => { - calls.push(1); - }); - expect(() => { - pair.app.setNotificationHandler("test/notification", { params }, () => { - calls.push(2); - }); - }).toThrow(/already registered/); - await pair.server.notification({ - method: "test/notification", - params: {}, - }); - - expect(calls).toEqual([1]); - }); - - it("direct setNotificationHandler throws for event-mapped methods (listener first)", () => { - const app = new App( - { name: "view-app", version: "1.0.0" }, - {}, - { autoResize: false }, - ); - app.addEventListener("toolinput", () => {}); - expect(() => { - app.setNotificationHandler( - "ui/notifications/tool-input", - { params: z.object({}) }, - () => {}, - ); - }).toThrow(/already registered/); - }); - - it("event registration throws when a direct handler already owns the method", () => { - const app = new App( - { name: "view-app", version: "1.0.0" }, - {}, - { autoResize: false }, - ); - app.setNotificationHandler( - "ui/notifications/tool-input", - { params: z.object({}) }, - () => {}, - ); - expect(() => { - app.ontoolinput = () => {}; - }).toThrow(/already registered/); - expect(() => { - app.addEventListener("toolinput", () => {}); - }).toThrow(/already registered/); - }); - - it("on* request setters keep replace semantics", async () => { - const pair = await connectPair(); - connected.push(pair); - let handledBy = 0; - pair.app.onteardown = async () => { - handledBy = 1; - return {}; - }; - pair.app.onteardown = async () => { - handledBy = 2; - return {}; - }; - await pair.server.request( - { method: "ui/resource-teardown", params: {} }, - z.object({}), - ); - expect(handledBy).toBe(2); - }); -}); diff --git a/src/app.ts b/src/app.ts index 89c6c35..e24913e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,45 +1,23 @@ import { - Protocol, - type BaseContext, - type ProtocolOptions, type RequestOptions, - type CallToolRequest, - type CallToolResult, - type CreateMessageRequest, - type CreateMessageResult, - type CreateMessageResultWithTools, - type Implementation, - type ListResourcesRequest, - type ListResourcesResult, - type ListToolsRequest, - type ListToolsResult, - type LoggingMessageNotification, - type ReadResourceRequest, - type ReadResourceResult, - type Tool, - type ToolAnnotations, - type ToolListChangedNotification, - type Transport, -} from "@modelcontextprotocol/client"; -import { EmptyResultSchema } from "@modelcontextprotocol/core"; -export { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./constants.js"; -import { EventDispatcher, MethodRegistry } from "./events.js"; -import { - toModernArgs, - type LegacyNotificationHandlerSetter, - type LegacyRequestHandlerSetter, -} from "./legacy-handlers.js"; -export type { - LegacyMethodSchema, - LegacyNotificationHandler, - LegacyNotificationHandlerSetter, - LegacyRequestHandler, - LegacyRequestHandlerExtra, - LegacyRequestHandlerSetter, -} from "./legacy-handlers.js"; -export { EventDispatcher } from "./events.js"; + Protocol, + ProtocolOptions, +} from "@modelcontextprotocol/sdk/shared/protocol.js"; -import { PostMessageTransport } from "./message-transport.js"; +import { + CallToolRequest, + CallToolRequestSchema, + CallToolResult, + CallToolResultSchema, + EmptyResultSchema, + Implementation, + ListToolsRequest, + ListToolsRequestSchema, + LoggingMessageNotification, + PingRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { AppNotification, AppRequest, AppResult } from "./types"; +import { PostMessageTransport } from "./message-transport"; import { LATEST_PROTOCOL_VERSION, McpUiAppCapabilities, @@ -55,13 +33,9 @@ import { McpUiMessageResultSchema, McpUiOpenLinkRequest, McpUiOpenLinkResultSchema, - McpUiDownloadFileRequest, - McpUiDownloadFileResultSchema, McpUiResourceTeardownRequest, McpUiResourceTeardownRequestSchema, McpUiResourceTeardownResult, - McpUiResourceTeardownResultSchema, - McpUiRequestTeardownNotification, McpUiSizeChangedNotification, McpUiToolCancelledNotification, McpUiToolCancelledNotificationSchema, @@ -73,187 +47,94 @@ import { McpUiToolResultNotificationSchema, McpUiRequestDisplayModeRequest, McpUiRequestDisplayModeResultSchema, -} from "./types.js"; -import { - StandardSchemaV1, - standardSchemaToJsonSchema, - validateStandardSchema, -} from "./standard-schema.js"; -import { z, type ZodLiteral, type ZodObject, type ZodType } from "zod/v4"; +} from "./types"; +import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; -type UntypedHandlerSetter = (this: unknown, ...args: unknown[]) => void; - -type MethodSchema = ZodObject<{ - method: ZodLiteral<string>; - params: ZodType; -}>; - -function mergeAppCapabilities( - base: McpUiAppCapabilities, - additional: McpUiAppCapabilities, -): McpUiAppCapabilities { - const result = { ...base, ...additional }; - if (additional.experimental !== undefined) { - result.experimental = { - ...base.experimental, - ...additional.experimental, - }; - } - if (additional.tools !== undefined) { - result.tools = { ...base.tools, ...additional.tools }; - } - return result; -} - -export type { - StandardSchemaV1, - StandardSchemaWithJSON, -} from "./standard-schema.js"; - -export { PostMessageTransport } from "./message-transport.js"; -export * from "./types.js"; +export { PostMessageTransport } from "./message-transport"; +export * from "./types"; export { applyHostStyleVariables, applyHostFonts, getDocumentTheme, applyDocumentTheme, -} from "./styles.js"; +} from "./styles"; /** - * Options for configuring {@link App `App`} behavior. + * Metadata key for associating a resource URI with a tool call. * - * Extends `ProtocolOptions` from the base MCP SDK with `App`-specific configuration. + * MCP servers include this key in tool call result metadata to indicate which + * UI resource should be displayed for the tool. When hosts receive a tool result + * containing this metadata, they resolve and render the corresponding {@link App}. * - * @see `ProtocolOptions` from @modelcontextprotocol/client for inherited options + * **Note**: This constant is provided for reference. MCP servers set this metadata + * in their tool handlers; App developers typically don't need to use it directly. + * + * @example How MCP servers use this key (server-side, not in Apps) + * ```typescript + * // In an MCP server's tool handler: + * return { + * content: [{ type: "text", text: "Result" }], + * _meta: { + * [RESOURCE_URI_META_KEY]: "ui://weather/forecast" + * } + * }; + * ``` + * + * @example How hosts check for this metadata (host-side) + * ```typescript + * const result = await mcpClient.callTool({ name: "weather", arguments: {} }); + * const uiUri = result._meta?.[RESOURCE_URI_META_KEY]; + * if (uiUri) { + * // Load and display the UI resource + * } + * ``` */ -export type AppOptions = Omit<ProtocolOptions, "supportedProtocolVersions"> & { +export const RESOURCE_URI_META_KEY = "ui/resourceUri"; + +/** + * MIME type for MCP UI resources. + * + * Identifies HTML content as an MCP App UI resource. + * + * Used by {@link server-helpers!registerAppResource} as the default MIME type for app resources. + */ +export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; + +/** + * Options for configuring {@link App} behavior. + * + * Extends `ProtocolOptions` from the MCP SDK with `App`-specific configuration. + * + * @see `ProtocolOptions` from @modelcontextprotocol/sdk for inherited options + */ +type AppOptions = ProtocolOptions & { /** * Automatically report size changes to the host using `ResizeObserver`. * - * When enabled, the {@link App `App`} monitors `document.body` and `document.documentElement` + * When enabled, the {@link App} monitors `document.body` and `document.documentElement` * for size changes and automatically sends `ui/notifications/size-changed` * notifications to the host. * * @default true */ autoResize?: boolean; - /** - * Throw on detected misuse instead of logging a console warning. - * - * Currently this affects calling host-bound methods (e.g. - * {@link App.callServerTool `callServerTool`}, {@link App.sendMessage `sendMessage`}) - * before {@link App.connect `connect`} has completed the `ui/initialize` - * handshake. With `strict: false` (default) a `console.warn` is emitted; - * with `strict: true` an `Error` is thrown. - * - * @remarks Throwing will become the default in a future release. - * @default false - */ - strict?: boolean; - /** - * Allow code paths that require CSP `unsafe-eval` (e.g. `new Function()`). - * - * Views typically run under a strict CSP without `unsafe-eval`. Zod's JIT - * object parser uses `new Function()` and throws on the first message parse - * under such a policy. By default (`allowUnsafeEval: false`) the - * {@link App `App`} constructor sets `z.config({ jitless: true })` so the - * SDK works out of the box under the spec's default CSP. Set - * `allowUnsafeEval: true` to skip that and keep the faster JIT path when - * the host's CSP permits `unsafe-eval`. - * - * @default false - */ - allowUnsafeEval?: boolean; }; -type RequestHandlerExtra = BaseContext; - -/** - * Result of an app-registered tool callback. When `Out` is provided, - * `structuredContent` is required and typed (unless `isError: true`). - */ -export type AppToolResult< - Out extends StandardSchemaV1 | undefined = undefined, -> = Out extends StandardSchemaV1 - ? - | (CallToolResult & { - structuredContent: StandardSchemaV1.InferOutput<Out>; - isError?: false; - }) - | (CallToolResult & { isError: true }) - : CallToolResult; - -/** - * Callback for an app-registered tool. When `In` is provided, `args` is the - * validated/parsed input; when `In` is `undefined`, the callback receives only - * `extra`. When `Out` is provided, the return's `structuredContent` is typed. - * - * Mirrors `ToolCallback` from `@modelcontextprotocol/server` but is - * parameterized over {@link StandardSchemaV1} instead of zod, so any - * Standard-Schema-compatible library (Zod, ArkType, Valibot, โ€ฆ) can be used. - */ -export type AppToolCallback< - In extends StandardSchemaV1 | undefined = undefined, - Out extends StandardSchemaV1 | undefined = undefined, -> = In extends StandardSchemaV1 - ? ( - args: StandardSchemaV1.InferOutput<In>, - extra: RequestHandlerExtra, - ) => AppToolResult<Out> | Promise<AppToolResult<Out>> - : ( - extra: RequestHandlerExtra, - ) => AppToolResult<Out> | Promise<AppToolResult<Out>>; - -/** - * Handle returned by {@link App.registerTool}. Mirrors `RegisteredTool` from - * `@modelcontextprotocol/server` but stores - * {@link StandardSchemaV1} schemas. - */ -export type RegisteredAppTool = { - title?: string; - description?: string; - inputSchema?: StandardSchemaV1; - outputSchema?: StandardSchemaV1; - annotations?: ToolAnnotations; - _meta?: Record<string, unknown>; - enabled: boolean; - enable(): void; - disable(): void; - remove(): void; - update(updates: Partial<Omit<RegisteredAppTool, "update">>): void; - /** @internal */ - handler: ( - args: unknown, - extra: RequestHandlerExtra, - ) => Promise<CallToolResult>; -}; - -/** - * Maps DOM-style event names to their notification `params` types. - * - * Used by {@link App `App`} and its composed {@link EventDispatcher} to - * provide type-safe `addEventListener` / `removeEventListener` and singular - * `on*` handler support. - */ -export type AppEventMap = { - toolinput: McpUiToolInputNotification["params"]; - toolinputpartial: McpUiToolInputPartialNotification["params"]; - toolresult: McpUiToolResultNotification["params"]; - toolcancelled: McpUiToolCancelledNotification["params"]; - hostcontextchanged: McpUiHostContextChangedNotification["params"]; -}; +type RequestHandlerExtra = Parameters< + Parameters<App["setRequestHandler"]>[1] +>[1]; /** * Main class for MCP Apps to communicate with their host. * * The `App` class provides a framework-agnostic way to build interactive MCP Apps - * that run inside host applications. It extends the base MCP SDK's public - * `Protocol` class, composes an {@link EventDispatcher}, and adds the Apps - * handshake, lifecycle, and high-level APIs. + * that run inside host applications. It extends the MCP SDK's `Protocol` class and + * handles the connection lifecycle, initialization handshake, and bidirectional + * communication with the host. * * ## Architecture * - * Views (Apps) act as MCP clients connecting to the host via {@link PostMessageTransport `PostMessageTransport`}. + * Guest UIs (Apps) act as MCP clients connecting to the host via {@link PostMessageTransport}. * The host proxies requests to the actual MCP server and forwards * responses back to the App. * @@ -262,507 +143,128 @@ export type AppEventMap = { * 1. **Create**: Instantiate App with info and capabilities * 2. **Connect**: Call `connect()` to establish transport and perform handshake * 3. **Interactive**: Send requests, receive notifications, call tools - * 4. **Teardown**: Host sends teardown request before unmounting + * 4. **Cleanup**: Host sends teardown request before unmounting * - * ## Protocol and event methods + * ## Inherited Methods * - * From the base MCP SDK's `Protocol`, `App` provides: + * As a subclass of `Protocol`, `App` inherits key methods for handling communication: * - `setRequestHandler()` - Register handlers for requests from host * - `setNotificationHandler()` - Register handlers for notifications from host * - * From its composed {@link EventDispatcher}, `App` also provides: - * - `addEventListener()` - Append a listener for a notification event (multi-listener) - * - `removeEventListener()` - Remove a previously added listener + * @see `Protocol` from @modelcontextprotocol/sdk for all inherited methods * - * ## Notification Setters (DOM-model `on*` handlers) + * ## Notification Setters * - * For common notifications, the `App` class provides getter/setter properties - * that follow DOM-model replace semantics (like `el.onclick`): + * For common notifications, the `App` class provides convenient setter properties + * that simplify handler registration: * - `ontoolinput` - Complete tool arguments from host * - `ontoolinputpartial` - Streaming partial tool arguments * - `ontoolresult` - Tool execution results * - `ontoolcancelled` - Tool execution was cancelled by user or host * - `onhostcontextchanged` - Host context changes (theme, locale, etc.) * - * Assigning replaces the previous handler; assigning `undefined` clears it. - * Use `addEventListener` to attach multiple listeners without replacing. + * These setters are convenience wrappers around `setNotificationHandler()`. + * Both patterns work; use whichever fits your coding style better. * * @example Basic usage with PostMessageTransport - * ```ts source="./app.examples.ts#App_basicUsage" + * ```typescript + * import { + * App, + * PostMessageTransport, + * McpUiToolInputNotificationSchema + * } from '@modelcontextprotocol/ext-apps'; + * * const app = new App( * { name: "WeatherApp", version: "1.0.0" }, - * {}, // capabilities + * {} // capabilities * ); * - * // Register handlers before connecting to ensure no notifications are missed + * // Register notification handler using setter (simpler) * app.ontoolinput = (params) => { * console.log("Tool arguments:", params.arguments); * }; * - * await app.connect(); + * // OR using inherited setNotificationHandler (more explicit) + * app.setNotificationHandler( + * McpUiToolInputNotificationSchema, + * (notification) => { + * console.log("Tool arguments:", notification.params.arguments); + * } + * ); + * + * await app.connect(new PostMessageTransport(window.parent, window.parent)); + * ``` + * + * @example Sending a message to the host's chat + * ```typescript + * await app.sendMessage({ + * role: "user", + * content: [{ type: "text", text: "Weather updated!" }] + * }); * ``` */ -export class App extends Protocol<BaseContext> { +export class App extends Protocol<AppRequest, AppNotification, AppResult> { private _hostCapabilities?: McpUiHostCapabilities; private _hostInfo?: Implementation; private _hostContext?: McpUiHostContext; - private _registeredTools: { [name: string]: RegisteredAppTool } = {}; - private _initializedSent = false; - private readonly _registeredEvents = new Set<keyof AppEventMap>(); - private readonly _events = new EventDispatcher<AppEventMap>(); - private readonly _methods = new MethodRegistry(); - - // โ”€โ”€ Handler registration with double-set protection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - // - // The base SDK `Protocol` silently replaces an existing handler. The four - // overrides below restore the v1 behaviour: a direct `setRequestHandler` / - // `setNotificationHandler` for a method that already has a handler throws, - // so a stray registration cannot silently disconnect `on*` handlers or - // `addEventListener` listeners. They are arrow-function class fields rather - // than prototype methods so that `Protocol`'s constructor โ€” which registers - // its own ping/cancelled/progress handlers before our fields initialize โ€” - // hits the base implementation and skips tracking. - - /** - * Registers a request handler. Throws if a handler for the same method has - * already been registered โ€” use the `on*` setter for replace semantics. - * - * @throws {Error} if a handler for this method is already registered. - */ - override setRequestHandler: LegacyRequestHandlerSetter< - Protocol<BaseContext>["setRequestHandler"] - > = (...args: unknown[]) => { - const [method, ...rest] = toModernArgs("request", args) ?? args; - this._methods.claim(method as string, "setRequestHandler"); - (super.setRequestHandler as unknown as UntypedHandlerSetter).call( - this, - method, - ...rest, - ); - }; - - /** - * Registers a notification handler. Throws if a handler for the same method - * has already been registered โ€” use the `on*` setter (replace semantics) or - * `addEventListener` (multi-listener) for mapped events. - * - * @throws {Error} if a handler for this method is already registered. - */ - override setNotificationHandler: LegacyNotificationHandlerSetter< - Protocol<BaseContext>["setNotificationHandler"] - > = (...args: unknown[]) => { - const [method, ...rest] = toModernArgs("notification", args) ?? args; - this._methods.claim(method as string, "setNotificationHandler"); - (super.setNotificationHandler as unknown as UntypedHandlerSetter).call( - this, - method, - ...rest, - ); - }; - - override removeRequestHandler: Protocol<BaseContext>["removeRequestHandler"] = - (method: string) => { - this._methods.release(method); - super.removeRequestHandler(method); - }; - - override removeNotificationHandler: Protocol<BaseContext>["removeNotificationHandler"] = - (method: string) => { - this._methods.release(method); - super.removeNotificationHandler(method); - }; - - /** - * Register a request handler with replace semantics, bypassing the - * double-set protection of {@link setRequestHandler `setRequestHandler`}. - * Used by the `on*` request-handler setters. - */ - protected replaceRequestHandler: Protocol<BaseContext>["setRequestHandler"] = - (method: string, ...rest: unknown[]) => { - this._methods.replace(method); - (super.setRequestHandler as unknown as UntypedHandlerSetter).call( - this, - method, - ...rest, - ); - }; - - /** - * Warn if a host-bound method is called before {@link connect `connect`} has - * completed the `ui/initialize` โ†’ `ui/notifications/initialized` handshake. - * - * Calling these methods early can race the handshake on strict hosts and - * leave the iframe permanently hidden. See - * {@link https://github.com/anthropics/claude-ai-mcp/issues/61 claude-ai-mcp#61} / - * {@link https://github.com/anthropics/claude-ai-mcp/issues/149 #149}. - * - * @remarks This will become a thrown `Error` in a future minor release. - */ - private _assertInitialized(method: string): void { - if (this._initializedSent) return; - const msg = - `[ext-apps] App.${method}() called before connect() completed the ` + - `ui/initialize handshake. Await app.connect() before calling this ` + - `method, or move data loading to an ontoolresult handler.`; - if (this.options?.strict) { - throw new Error(msg); - } - // TODO(next-minor): make `strict: true` the default. - console.warn(`${msg}. This will throw in a future release.`); - } - - private readonly eventSchemas: { - [K in keyof AppEventMap]: MethodSchema; - } = { - toolinput: McpUiToolInputNotificationSchema, - toolinputpartial: McpUiToolInputPartialNotificationSchema, - toolresult: McpUiToolResultNotificationSchema, - toolcancelled: McpUiToolCancelledNotificationSchema, - hostcontextchanged: McpUiHostContextChangedNotificationSchema, - }; - - /** - * Events the host typically sends once, shortly after the handshake. - * Registering a handler for one of these *after* {@link connect `connect`} - * resolves risks missing the notification entirely. - */ - private static readonly ONE_SHOT_EVENTS: ReadonlySet<keyof AppEventMap> = - new Set(["toolinput", "toolinputpartial", "toolresult", "toolcancelled"]); - - /** - * One-shot events that have had at least one handler registered (via `on*` - * setter or `addEventListener`) at any point. Once an event is in this set, - * subsequent late registrations are not flagged โ€” only the *first* handler - * matters for the missed-notification race, and re-registration (e.g. React - * `useEffect` cleanup โ†’ re-add on dep change) is a legitimate pattern. - */ - private readonly _everHadListener = new Set<keyof AppEventMap>(); - - /** - * Warn (or throw under `strict`) when the *first* handler for a one-shot - * event is registered after the `ui/initialize` โ†’ `ui/notifications/initialized` - * handshake has completed. The host may have already fired the notification - * by then. Subsequent registrations for the same event are not flagged. - * - * Mirrors {@link _assertInitialized `_assertInitialized`} (the outbound-side guard). - */ - private _assertHandlerTiming(event: keyof AppEventMap): void { - if (!App.ONE_SHOT_EVENTS.has(event) || this._everHadListener.has(event)) { - return; - } - this._everHadListener.add(event); - if (!this._initializedSent) return; - const msg = - `[ext-apps] "${String(event)}" handler registered after connect() ` + - `completed the ui/initialize handshake. The host may have already sent ` + - `this notification. Register handlers before calling app.connect().`; - if (this.options?.strict) { - throw new Error(msg); - } - console.warn(msg); - } - - protected setEventHandler<K extends keyof AppEventMap>( - event: K, - handler: ((params: AppEventMap[K]) => void) | undefined, - ): void { - if (handler) this._assertHandlerTiming(event); - this._ensureEventSlot(event); - this._events.setHandler(event, handler); - } - - addEventListener<K extends keyof AppEventMap>( - event: K, - handler: (params: AppEventMap[K]) => void, - ): void { - this._assertHandlerTiming(event); - this._ensureEventSlot(event); - this._events.addEventListener(event, handler); - } - - removeEventListener<K extends keyof AppEventMap>( - event: K, - handler: (params: AppEventMap[K]) => void, - ): void { - this._events.removeEventListener(event, handler); - } - - private _ensureEventSlot<K extends keyof AppEventMap>(event: K): void { - if (this._registeredEvents.has(event)) return; - const schema = this.eventSchemas[event]; - if (!schema) throw new Error(`Unknown event: ${String(event)}`); - this.setNotificationHandler( - schema.shape.method.value, - { params: schema.shape.params }, - (params) => { - const eventParams = params as AppEventMap[K]; - if (event === "hostcontextchanged") { - this._hostContext = { ...this._hostContext, ...eventParams }; - } - this._events.dispatch(event, eventParams); - }, - ); - this._registeredEvents.add(event); - } - - protected getEventHandler<K extends keyof AppEventMap>( - event: K, - ): ((params: AppEventMap[K]) => void) | undefined { - return this._events.getHandler(event); - } - - protected warnIfRequestHandlerReplaced( - name: string, - previous: unknown, - next: unknown, - ): void { - if (previous && next) { - console.warn( - `[MCP Apps] ${name} handler replaced. ` + - `Previous handler will no longer be called.`, - ); - } - } /** * Create a new MCP App instance. * * @param _appInfo - App identification (name and version) - * @param _appCapabilities - Features and capabilities this app provides + * @param _capabilities - Features and capabilities this app provides * @param options - Configuration options including `autoResize` behavior * * @example - * ```ts source="./app.examples.ts#App_constructor_basic" + * ```typescript * const app = new App( * { name: "MyApp", version: "1.0.0" }, * { tools: { listChanged: true } }, // capabilities - * { autoResize: true }, // options + * { autoResize: true } // options * ); * ``` */ constructor( private _appInfo: Implementation, - private _appCapabilities: McpUiAppCapabilities = {}, + private _capabilities: McpUiAppCapabilities = {}, private options: AppOptions = { autoResize: true }, ) { super(options); - // Claim the handlers the base Protocol constructor installed so a direct - // setNotificationHandler cannot silently replace them either. - this._methods.replace("notifications/cancelled"); - this._methods.replace("notifications/progress"); - if (!options.allowUnsafeEval) { - z.config({ jitless: true }); - } - - this.replaceRequestHandler("ping", (request) => { + this.setRequestHandler(PingRequestSchema, (request) => { console.log("Received ping:", request.params); return {}; }); - // Eagerly register hostcontextchanged so the owner-side context merge runs - // even if the user never assigns a handler or adds a listener. - this.setEventHandler("hostcontextchanged", undefined); - } - - /** @internal */ - protected buildContext(ctx: BaseContext): BaseContext { - return ctx; - } - - private registerAppCapabilities(capabilities: McpUiAppCapabilities): void { - if (this.transport) { - throw new Error( - "Cannot register capabilities after transport is established", - ); - } - this._appCapabilities = mergeAppCapabilities( - this._appCapabilities, - capabilities, - ); - } - - registerTool< - OutputArgs extends undefined | StandardSchemaV1 = undefined, - InputArgs extends undefined | StandardSchemaV1 = undefined, - >( - name: string, - config: { - title?: string; - description?: string; - inputSchema?: InputArgs; - outputSchema?: OutputArgs; - annotations?: ToolAnnotations; - _meta?: Record<string, unknown>; - }, - cb: AppToolCallback<InputArgs, OutputArgs>, - ): RegisteredAppTool { - if (this._registeredTools[name]) { - throw new Error(`Tool ${name} is already registered`); - } - const app = this; - const notify = () => { - if (app._initializedSent && app._appCapabilities.tools?.listChanged) { - void app.sendToolListChanged(); - } - }; - // Arity is fixed at registration time even if inputSchema is later - // update()d, since cb's signature can't change. - const cbTakesArgs = config.inputSchema !== undefined; - const registeredTool: RegisteredAppTool = { - title: config.title, - description: config.description, - inputSchema: config.inputSchema, - outputSchema: config.outputSchema, - annotations: config.annotations, - _meta: config._meta, - enabled: true, - enable(): void { - this.enabled = true; - notify(); - }, - disable(): void { - this.enabled = false; - notify(); - }, - update(updates) { - Object.assign(this, updates); - notify(); - }, - remove() { - if (app._registeredTools[name] !== registeredTool) return; - delete app._registeredTools[name]; - notify(); - }, - handler: async (rawArgs, extra) => { - if (!registeredTool.enabled) { - throw new Error(`Tool ${name} is disabled`); - } - let result: CallToolResult; - if (cbTakesArgs) { - const schema = registeredTool.inputSchema; - const parsedArgs = schema - ? await validateStandardSchema( - schema, - rawArgs ?? {}, - `Invalid input for tool ${name}: `, - ) - : (rawArgs ?? {}); - result = await ( - cb as AppToolCallback<StandardSchemaV1, StandardSchemaV1> - )(parsedArgs, extra); - } else { - result = await (cb as AppToolCallback<undefined, StandardSchemaV1>)( - extra, - ); - } - if (registeredTool.outputSchema && !result.isError) { - result.structuredContent = (await validateStandardSchema( - registeredTool.outputSchema, - result.structuredContent, - `Invalid output for tool ${name}: `, - )) as CallToolResult["structuredContent"]; - } - return result; - }, - }; - - this._registeredTools[name] = registeredTool; - - // Auto-register tools capability so setRequestHandler's capability check - // passes. Mirrors McpServer.registerTool behavior โ€” callers shouldn't need - // to declare { tools: {} } in the constructor just to use registerTool. - // Only do this pre-connect; post-connect the capability was already - // advertised (or wasn't) and can't change. - if (!this._appCapabilities.tools && !this.transport) { - this.registerAppCapabilities({ tools: { listChanged: true } }); - } - - this.ensureToolHandlersInitialized(); - notify(); - return registeredTool; - } - - private _toolHandlersInitialized = false; - private ensureToolHandlersInitialized(): void { - if (this._toolHandlersInitialized) { - return; - } - this._toolHandlersInitialized = true; - - this.oncalltool = async (params, extra) => { - const tool = this._registeredTools[params.name]; - if (!tool) { - throw new Error(`Tool ${params.name} not found`); - } - return tool.handler(params.arguments, extra); - }; - this.onlisttools = async (_params, _extra) => { - const tools: Tool[] = await Promise.all( - Object.entries(this._registeredTools) - .filter(([_, tool]) => tool.enabled) - .map(async ([name, tool]) => { - const result: Tool = { - name, - title: tool.title, - description: tool.description, - inputSchema: (tool.inputSchema - ? await standardSchemaToJsonSchema(tool.inputSchema, "input") - : { - type: "object" as const, - properties: {}, - }) as Tool["inputSchema"], - }; - // outputSchema is optional in core MCP โ€” only emit when the app - // provided one, otherwise hosts would assume structuredContent. - if (tool.outputSchema) { - result.outputSchema = (await standardSchemaToJsonSchema( - tool.outputSchema, - "output", - )) as Tool["outputSchema"]; - } - if (tool.annotations) { - result.annotations = tool.annotations; - } - if (tool._meta) { - result._meta = tool._meta; - } - return result; - }), - ); - return { tools }; - }; - } - - async sendToolListChanged( - params: ToolListChangedNotification["params"] = {}, - ): Promise<void> { - this._assertInitialized("sendToolListChanged"); - await this.notification(<ToolListChangedNotification>{ - method: "notifications/tools/list_changed", - params, - }); + // Set up default handler to update _hostContext when notifications arrive. + // Users can override this by setting onhostcontextchanged. + this.onhostcontextchanged = () => {}; } /** * Get the host's capabilities discovered during initialization. * * Returns the capabilities that the host advertised during the - * {@link connect `connect`} handshake. Returns `undefined` if called before + * {@link connect} handshake. Returns `undefined` if called before * connection is established. * * @returns Host capabilities, or `undefined` if not yet connected * * @example Check host capabilities after connection - * ```ts source="./app.examples.ts#App_getHostCapabilities_checkAfterConnection" - * await app.connect(); - * if (app.getHostCapabilities()?.serverTools) { + * ```typescript + * await app.connect(transport); + * const caps = app.getHostCapabilities(); + * if (caps === undefined) { + * console.error("Not connected"); + * return; + * } + * if (caps.serverTools) { * console.log("Host supports server tool calls"); * } * ``` * - * @see {@link connect `connect`} for the initialization handshake - * @see {@link McpUiHostCapabilities `McpUiHostCapabilities`} for the capabilities structure + * @see {@link connect} for the initialization handshake + * @see {@link McpUiHostCapabilities} for the capabilities structure */ getHostCapabilities(): McpUiHostCapabilities | undefined { return this._hostCapabilities; @@ -772,19 +274,23 @@ export class App extends Protocol<BaseContext> { * Get the host's implementation info discovered during initialization. * * Returns the host's name and version as advertised during the - * {@link connect `connect`} handshake. Returns `undefined` if called before + * {@link connect} handshake. Returns `undefined` if called before * connection is established. * * @returns Host implementation info, or `undefined` if not yet connected * * @example Log host information after connection - * ```ts source="./app.examples.ts#App_getHostVersion_logAfterConnection" + * ```typescript * await app.connect(transport); - * const { name, version } = app.getHostVersion() ?? {}; - * console.log(`Connected to ${name} v${version}`); + * const host = app.getHostVersion(); + * if (host === undefined) { + * console.error("Not connected"); + * return; + * } + * console.log(`Connected to ${host.name} v${host.version}`); * ``` * - * @see {@link connect `connect`} for the initialization handshake + * @see {@link connect} for the initialization handshake */ getHostVersion(): Implementation | undefined { return this._hostInfo; @@ -803,20 +309,24 @@ export class App extends Protocol<BaseContext> { * @returns Host context, or `undefined` if not yet connected * * @example Access host context after connection - * ```ts source="./app.examples.ts#App_getHostContext_accessAfterConnection" + * ```typescript * await app.connect(transport); * const context = app.getHostContext(); - * if (context?.theme === "dark") { + * if (context === undefined) { + * console.error("Not connected"); + * return; + * } + * if (context.theme === "dark") { * document.body.classList.add("dark-theme"); * } - * if (context?.toolInfo) { + * if (context.toolInfo) { * console.log("Tool:", context.toolInfo.tool.name); * } * ``` * - * @see {@link connect `connect`} for the initialization handshake - * @see {@link onhostcontextchanged `onhostcontextchanged`} for context change notifications - * @see {@link McpUiHostContext `McpUiHostContext`} for the context structure + * @see {@link connect} for the initialization handshake + * @see {@link onhostcontextchanged} for context change notifications + * @see {@link McpUiHostContext} for the context structure */ getHostContext(): McpUiHostContext | undefined { return this._hostContext; @@ -829,36 +339,42 @@ export class App extends Protocol<BaseContext> { * sends a tool's complete arguments. This is sent after a tool call begins * and before the tool result is available. * - * Assigning replaces the previous handler; assigning `undefined` clears it. - * Use {@link addEventListener `addEventListener`} to attach multiple listeners - * without replacing. + * This setter is a convenience wrapper around `setNotificationHandler()` that + * automatically handles the notification schema and extracts the params for you. * - * Register handlers before calling {@link connect `connect`} to avoid missing notifications. + * Register handlers before calling {@link connect} to avoid missing notifications. * - * @example - * ```ts source="./app.examples.ts#App_ontoolinput_setter" + * @param callback - Function called with the tool input params ({@link McpUiToolInputNotification.params}) + * + * @example Using the setter (simpler) + * ```typescript * // Register before connecting to ensure no notifications are missed * app.ontoolinput = (params) => { * console.log("Tool:", params.arguments); * // Update your UI with the tool arguments * }; - * await app.connect(); + * await app.connect(transport); * ``` * - * @deprecated Use {@link addEventListener `addEventListener("toolinput", handler)`} instead โ€” it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}. - * @see {@link McpUiToolInputNotification `McpUiToolInputNotification`} for the notification structure + * @example Using setNotificationHandler (more explicit) + * ```typescript + * app.setNotificationHandler( + * McpUiToolInputNotificationSchema, + * (notification) => { + * console.log("Tool:", notification.params.arguments); + * } + * ); + * ``` + * + * @see {@link setNotificationHandler} for the underlying method + * @see {@link McpUiToolInputNotification} for the notification structure */ - get ontoolinput(): - | ((params: McpUiToolInputNotification["params"]) => void) - | undefined { - return this.getEventHandler("toolinput"); - } set ontoolinput( - callback: - | ((params: McpUiToolInputNotification["params"]) => void) - | undefined, + callback: (params: McpUiToolInputNotification["params"]) => void, ) { - this.setEventHandler("toolinput", callback); + this.setNotificationHandler(McpUiToolInputNotificationSchema, (n) => + callback(n.params), + ); } /** @@ -868,50 +384,31 @@ export class App extends Protocol<BaseContext> { * streams partial tool arguments during tool call initialization. This enables * progressive rendering of tool arguments before they're complete. * - * **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). Use partial data only - * for preview UI, not for critical operations. + * This setter is a convenience wrapper around `setNotificationHandler()` that + * automatically handles the notification schema and extracts the params for you. * - * Assigning replaces the previous handler; assigning `undefined` clears it. - * Use {@link addEventListener `addEventListener`} to attach multiple listeners - * without replacing. + * Register handlers before calling {@link connect} to avoid missing notifications. * - * Register handlers before calling {@link connect `connect`} to avoid missing notifications. + * @param callback - Function called with each partial tool input update ({@link McpUiToolInputPartialNotification.params}) * * @example Progressive rendering of tool arguments - * ```ts source="./app.examples.ts#App_ontoolinputpartial_progressiveRendering" - * const codePreview = document.querySelector<HTMLPreElement>("#code-preview")!; - * const canvas = document.querySelector<HTMLCanvasElement>("#canvas")!; - * + * ```typescript * 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); + * console.log("Partial args:", params.arguments); + * // Update your UI progressively as arguments stream in * }; * ``` * - * @deprecated Use {@link addEventListener `addEventListener("toolinputpartial", handler)`} instead โ€” it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}. - * @see {@link McpUiToolInputPartialNotification `McpUiToolInputPartialNotification`} for the notification structure - * @see {@link ontoolinput `ontoolinput`} for the complete tool input handler + * @see {@link setNotificationHandler} for the underlying method + * @see {@link McpUiToolInputPartialNotification} for the notification structure + * @see {@link ontoolinput} for the complete tool input handler */ - get ontoolinputpartial(): - | ((params: McpUiToolInputPartialNotification["params"]) => void) - | undefined { - return this.getEventHandler("toolinputpartial"); - } set ontoolinputpartial( - callback: - | ((params: McpUiToolInputPartialNotification["params"]) => void) - | undefined, + callback: (params: McpUiToolInputPartialNotification["params"]) => void, ) { - this.setEventHandler("toolinputpartial", callback); + this.setNotificationHandler(McpUiToolInputPartialNotificationSchema, (n) => + callback(n.params), + ); } /** @@ -921,38 +418,35 @@ export class App extends Protocol<BaseContext> { * sends the result of a tool execution. This is sent after the tool completes * on the MCP server, allowing your app to display the results or update its state. * - * Assigning replaces the previous handler; assigning `undefined` clears it. - * Use {@link addEventListener `addEventListener`} to attach multiple listeners - * without replacing. + * This setter is a convenience wrapper around `setNotificationHandler()` that + * automatically handles the notification schema and extracts the params for you. * - * Register handlers before calling {@link connect `connect`} to avoid missing notifications. + * Register handlers before calling {@link connect} to avoid missing notifications. + * + * @param callback - Function called with the tool result ({@link McpUiToolResultNotification.params}) * * @example Display tool execution results - * ```ts source="./app.examples.ts#App_ontoolresult_displayResults" + * ```typescript * app.ontoolresult = (params) => { - * if (params.isError) { - * console.error("Tool execution failed:", params.content); - * } else if (params.content) { + * if (params.content) { * console.log("Tool output:", params.content); * } + * if (params.isError) { + * console.error("Tool execution failed"); + * } * }; * ``` * - * @deprecated Use {@link addEventListener `addEventListener("toolresult", handler)`} instead โ€” it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}. - * @see {@link McpUiToolResultNotification `McpUiToolResultNotification`} for the notification structure - * @see {@link ontoolinput `ontoolinput`} for the initial tool input handler + * @see {@link setNotificationHandler} for the underlying method + * @see {@link McpUiToolResultNotification} for the notification structure + * @see {@link ontoolinput} for the initial tool input handler */ - get ontoolresult(): - | ((params: McpUiToolResultNotification["params"]) => void) - | undefined { - return this.getEventHandler("toolresult"); - } set ontoolresult( - callback: - | ((params: McpUiToolResultNotification["params"]) => void) - | undefined, + callback: (params: McpUiToolResultNotification["params"]) => void, ) { - this.setEventHandler("toolresult", callback); + this.setNotificationHandler(McpUiToolResultNotificationSchema, (n) => + callback(n.params), + ); } /** @@ -963,35 +457,31 @@ export class App extends Protocol<BaseContext> { * including user action, sampling error, classifier intervention, or other * interruptions. Apps should update their state and display appropriate feedback. * - * Assigning replaces the previous handler; assigning `undefined` clears it. - * Use {@link addEventListener `addEventListener`} to attach multiple listeners - * without replacing. + * This setter is a convenience wrapper around `setNotificationHandler()` that + * automatically handles the notification schema and extracts the params for you. * - * Register handlers before calling {@link connect `connect`} to avoid missing notifications. + * Register handlers before calling {@link connect} to avoid missing notifications. + * + * @param callback - Function called when tool execution is cancelled. Receives optional cancellation reason โ€” see {@link McpUiToolCancelledNotification.params}. * * @example Handle tool cancellation - * ```ts source="./app.examples.ts#App_ontoolcancelled_handleCancellation" + * ```typescript * app.ontoolcancelled = (params) => { * console.log("Tool cancelled:", params.reason); - * // Update your UI to show cancellation state + * showCancelledMessage(params.reason ?? "Operation was cancelled"); * }; * ``` * - * @deprecated Use {@link addEventListener `addEventListener("toolcancelled", handler)`} instead โ€” it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}. - * @see {@link McpUiToolCancelledNotification `McpUiToolCancelledNotification`} for the notification structure - * @see {@link ontoolresult `ontoolresult`} for successful tool completion + * @see {@link setNotificationHandler} for the underlying method + * @see {@link McpUiToolCancelledNotification} for the notification structure + * @see {@link ontoolresult} for successful tool completion */ - get ontoolcancelled(): - | ((params: McpUiToolCancelledNotification["params"]) => void) - | undefined { - return this.getEventHandler("toolcancelled"); - } set ontoolcancelled( - callback: - | ((params: McpUiToolCancelledNotification["params"]) => void) - | undefined, + callback: (params: McpUiToolCancelledNotification["params"]) => void, ) { - this.setEventHandler("toolcancelled", callback); + this.setNotificationHandler(McpUiToolCancelledNotificationSchema, (n) => + callback(n.params), + ); } /** @@ -1002,21 +492,21 @@ export class App extends Protocol<BaseContext> { * other environmental updates. Apps should respond by updating their UI * accordingly. * - * Assigning replaces the previous handler; assigning `undefined` clears it. - * Use {@link addEventListener `addEventListener`} to attach multiple listeners - * without replacing. + * This setter is a convenience wrapper around `setNotificationHandler()` that + * automatically handles the notification schema and extracts the params for you. * * Notification params are automatically merged into the internal host context - * before any handler or listener fires. This means - * {@link getHostContext `getHostContext`} will return the updated values even - * before your callback runs. + * before the callback is invoked. This means {@link getHostContext} will + * return the updated values even before your callback runs. * - * Register handlers before calling {@link connect `connect`} to avoid missing notifications. + * Register handlers before calling {@link connect} to avoid missing notifications. + * + * @param callback - Function called with the updated host context * * @example Respond to theme changes - * ```ts source="./app.examples.ts#App_onhostcontextchanged_respondToTheme" - * app.onhostcontextchanged = (ctx) => { - * if (ctx.theme === "dark") { + * ```typescript + * app.onhostcontextchanged = (params) => { + * if (params.theme === "dark") { * document.body.classList.add("dark-theme"); * } else { * document.body.classList.remove("dark-theme"); @@ -1024,21 +514,21 @@ export class App extends Protocol<BaseContext> { * }; * ``` * - * @deprecated Use {@link addEventListener `addEventListener("hostcontextchanged", handler)`} instead โ€” it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}. - * @see {@link McpUiHostContextChangedNotification `McpUiHostContextChangedNotification`} for the notification structure - * @see {@link McpUiHostContext `McpUiHostContext`} for the full context structure + * @see {@link setNotificationHandler} for the underlying method + * @see {@link McpUiHostContextChangedNotification} for the notification structure + * @see {@link McpUiHostContext} for the full context structure */ - get onhostcontextchanged(): - | ((params: McpUiHostContextChangedNotification["params"]) => void) - | undefined { - return this.getEventHandler("hostcontextchanged"); - } set onhostcontextchanged( - callback: - | ((params: McpUiHostContextChangedNotification["params"]) => void) - | undefined, + callback: (params: McpUiHostContextChangedNotification["params"]) => void, ) { - this.setEventHandler("hostcontextchanged", callback); + this.setNotificationHandler( + McpUiHostContextChangedNotificationSchema, + (n) => { + // Merge the partial update into the stored context + this._hostContext = { ...this._hostContext, ...n.params }; + callback(n.params); + }, + ); } /** @@ -1051,15 +541,16 @@ export class App extends Protocol<BaseContext> { * The handler can be sync or async. The host will wait for the returned promise * to resolve before proceeding with teardown. * - * Assigning replaces the previous handler; assigning `undefined` clears it. + * This setter is a convenience wrapper around `setRequestHandler()` that + * automatically handles the request schema. * - * Register handlers before calling {@link connect `connect`} to avoid missing requests. + * Register handlers before calling {@link connect} to avoid missing requests. * * @param callback - Function called when teardown is requested. * Must return `McpUiResourceTeardownResult` (can be an empty object `{}`) or a Promise resolving to it. * * @example Perform cleanup before teardown - * ```ts source="./app.examples.ts#App_onteardown_performCleanup" + * ```typescript * app.onteardown = async () => { * await saveState(); * closeConnections(); @@ -1068,35 +559,18 @@ export class App extends Protocol<BaseContext> { * }; * ``` * - * @see {@link McpUiResourceTeardownRequest `McpUiResourceTeardownRequest`} for the request structure + * @see {@link setRequestHandler} for the underlying method + * @see {@link McpUiResourceTeardownRequest} for the request structure */ - private _onteardown?: ( - params: McpUiResourceTeardownRequest["params"], - extra: RequestHandlerExtra, - ) => McpUiResourceTeardownResult | Promise<McpUiResourceTeardownResult>; - get onteardown() { - return this._onteardown; - } set onteardown( - callback: - | (( - params: McpUiResourceTeardownRequest["params"], - extra: RequestHandlerExtra, - ) => McpUiResourceTeardownResult | Promise<McpUiResourceTeardownResult>) - | undefined, + callback: ( + params: McpUiResourceTeardownRequest["params"], + extra: RequestHandlerExtra, + ) => McpUiResourceTeardownResult | Promise<McpUiResourceTeardownResult>, ) { - this.warnIfRequestHandlerReplaced("onteardown", this._onteardown, callback); - this._onteardown = callback; - this.replaceRequestHandler( - "ui/resource-teardown", - { - params: McpUiResourceTeardownRequestSchema.shape.params, - result: McpUiResourceTeardownResultSchema, - }, - (params, extra) => { - if (!this._onteardown) throw new Error("No onteardown handler set"); - return this._onteardown(params, extra); - }, + this.setRequestHandler( + McpUiResourceTeardownRequestSchema, + (request, extra) => callback(request.params, extra), ); } @@ -1109,16 +583,17 @@ export class App extends Protocol<BaseContext> { * * The app must declare tool capabilities in the constructor to use this handler. * - * Assigning replaces the previous handler; assigning `undefined` clears it. + * This setter is a convenience wrapper around `setRequestHandler()` that + * automatically handles the request schema and extracts the params for you. * - * Register handlers before calling {@link connect `connect`} to avoid missing requests. + * Register handlers before calling {@link connect} to avoid missing requests. * * @param callback - Async function that executes the tool and returns the result. * The callback will only be invoked if the app declared tool capabilities * in the constructor. * * @example Handle tool calls from the host - * ```ts source="./app.examples.ts#App_oncalltool_handleFromHost" + * ```typescript * app.oncalltool = async (params, extra) => { * if (params.name === "greet") { * const name = params.arguments?.name ?? "World"; @@ -1127,28 +602,18 @@ export class App extends Protocol<BaseContext> { * throw new Error(`Unknown tool: ${params.name}`); * }; * ``` + * + * @see {@link setRequestHandler} for the underlying method */ - private _oncalltool?: ( - params: CallToolRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<CallToolResult>; - get oncalltool() { - return this._oncalltool; - } set oncalltool( - callback: - | (( - params: CallToolRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<CallToolResult>) - | undefined, + callback: ( + params: CallToolRequest["params"], + extra: RequestHandlerExtra, + ) => Promise<CallToolResult>, ) { - this.warnIfRequestHandlerReplaced("oncalltool", this._oncalltool, callback); - this._oncalltool = callback; - this.replaceRequestHandler("tools/call", (request, extra) => { - if (!this._oncalltool) throw new Error("No oncalltool handler set"); - return this._oncalltool(request.params, extra); - }); + this.setRequestHandler(CallToolRequestSchema, (request, extra) => + callback(request.params, extra), + ); } /** @@ -1160,103 +625,65 @@ export class App extends Protocol<BaseContext> { * * The app must declare tool capabilities in the constructor to use this handler. * - * Assigning replaces the previous handler; assigning `undefined` clears it. + * This setter is a convenience wrapper around `setRequestHandler()` that + * automatically handles the request schema and extracts the params for you. * - * Register handlers before calling {@link connect `connect`} to avoid missing requests. + * Register handlers before calling {@link connect} to avoid missing requests. * - * @param callback - Async function that returns a {@link ListToolsResult `ListToolsResult`}. - * Registration is always allowed; capability validation occurs when handlers - * are invoked. + * @param callback - Async function that returns tool names as strings (simplified + * from full `ListToolsResult` with `Tool` objects). Registration is always + * allowed; capability validation occurs when handlers are invoked. * * @example Return available tools - * ```ts source="./app.examples.ts#App_onlisttools_returnTools" + * ```typescript * app.onlisttools = async (params, extra) => { * return { - * tools: [ - * { - * name: "greet", - * description: "Greet the user", - * inputSchema: { type: "object" as const }, - * }, - * { - * name: "calculate", - * description: "Perform a calculation", - * inputSchema: { type: "object" as const }, - * }, - * { - * name: "format", - * description: "Format text", - * inputSchema: { type: "object" as const }, - * }, - * ], + * tools: ["calculate", "convert", "format"] * }; * }; * ``` * - * @see {@link oncalltool `oncalltool`} for handling tool execution + * @see {@link setRequestHandler} for the underlying method + * @see {@link oncalltool} for handling tool execution */ - private _onlisttools?: ( - params: ListToolsRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<ListToolsResult>; - get onlisttools() { - return this._onlisttools; - } set onlisttools( - callback: - | (( - params: ListToolsRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<ListToolsResult>) - | undefined, + callback: ( + params: ListToolsRequest["params"], + extra: RequestHandlerExtra, + ) => Promise<{ tools: string[] }>, ) { - this.warnIfRequestHandlerReplaced( - "onlisttools", - this._onlisttools, - callback, + this.setRequestHandler(ListToolsRequestSchema, (request, extra) => + callback(request.params, extra), ); - this._onlisttools = callback; - this.replaceRequestHandler("tools/list", (request, extra) => { - if (!this._onlisttools) throw new Error("No onlisttools handler set"); - return this._onlisttools(request.params, extra); - }); } /** * Verify that the host supports the capability required for the given request method. * @internal */ - protected override assertCapabilityForMethod(method: string): void { - switch (method) { - case "sampling/createMessage": - if (!this._hostCapabilities?.sampling) { - throw new Error( - `Host does not support sampling (required for ${method})`, - ); - } - break; - } + assertCapabilityForMethod(method: AppRequest["method"]): void { + // TODO } /** * Verify that the app declared the capability required for the given request method. * @internal */ - protected override assertRequestHandlerCapability(method: string): void { + assertRequestHandlerCapability(method: AppRequest["method"]): void { switch (method) { case "tools/call": case "tools/list": - if (!this._appCapabilities.tools) { + if (!this._capabilities.tools) { throw new Error( `Client does not support tool capability (required for ${method})`, ); } return; - default: - // `ping`, `ui/*`, and custom (vendor-prefixed) methods need no - // declared capability. The base SDK explicitly supports custom - // request handlers via the `{ params, result }` form. + case "ping": + case "ui/resource-teardown": return; + default: + throw new Error(`No handler for method ${method} registered`); } } @@ -1264,10 +691,26 @@ export class App extends Protocol<BaseContext> { * Verify that the app supports the capability required for the given notification method. * @internal */ - protected override assertNotificationCapability(_method: string): void { + assertNotificationCapability(method: AppNotification["method"]): void { // TODO } + /** + * Verify that task creation is supported for the given request method. + * @internal + */ + protected assertTaskCapability(_method: string): void { + throw new Error("Tasks are not supported in MCP Apps"); + } + + /** + * Verify that task handler is supported for the given method. + * @internal + */ + protected assertTaskHandlerCapability(_method: string): void { + throw new Error("Task handlers are not supported in MCP Apps"); + } + /** * Call a tool on the originating MCP server (proxied through the host). * @@ -1287,11 +730,11 @@ export class App extends Protocol<BaseContext> { * between transport failures (thrown) and tool execution failures (returned). * * @example Fetch updated weather data - * ```ts source="./app.examples.ts#App_callServerTool_fetchWeather" + * ```typescript * try { * const result = await app.callServerTool({ * name: "get_weather", - * arguments: { location: "Tokyo" }, + * arguments: { location: "Tokyo" } * }); * if (result.isError) { * console.error("Tool returned error:", result.content); @@ -1307,194 +750,9 @@ export class App extends Protocol<BaseContext> { params: CallToolRequest["params"], options?: RequestOptions, ): Promise<CallToolResult> { - this._assertInitialized("callServerTool"); - if (typeof params === "string") { - throw new Error( - `callServerTool() expects an object as its first argument, but received a string ("${params}"). ` + - `Did you mean: callServerTool({ name: "${params}", arguments: { ... } })?`, - ); - } return await this.request( { method: "tools/call", params }, - { - // Hosts may interpose long-running or user-interactive steps before the - // tool result arrives. Opting in here lets a host heartbeat keep the - // request alive past the default timeout; callers can still override. - onprogress: () => {}, - resetTimeoutOnProgress: true, - ...options, - }, - ); - } - - /** - * Read a resource from the originating MCP server (proxied through the host). - * - * Apps can read resources to access files, data, or other content provided by - * the MCP server. Resources are identified by URI (e.g., `file:///path/to/file` - * or custom schemes like `videos://bunny-1mb`). The host proxies the request to - * the actual MCP server and returns the resource content. - * - * @param params - Resource URI to read - * @param options - Request options (timeout, etc.) - * @returns Resource content with URI, name, description, mimeType, and contents array - * - * @throws {Error} If the resource does not exist on the server - * @throws {Error} If the request times out or the connection is lost - * @throws {Error} If the host rejects the request - * - * @example Read a video resource and play it - * ```ts source="./app.examples.ts#App_readServerResource_playVideo" - * try { - * const result = await app.readServerResource({ - * uri: "videos://bunny-1mb", - * }); - * const content = result.contents[0]; - * if (content && "blob" in content) { - * const binary = Uint8Array.from(atob(content.blob), (c) => - * c.charCodeAt(0), - * ); - * const url = URL.createObjectURL( - * new Blob([binary], { type: content.mimeType || "video/mp4" }), - * ); - * videoElement.src = url; - * videoElement.play(); - * } - * } catch (error) { - * console.error("Failed to read resource:", error); - * } - * ``` - * - * @see {@link listServerResources `listServerResources`} to discover available resources - */ - async readServerResource( - params: ReadResourceRequest["params"], - options?: RequestOptions, - ): Promise<ReadResourceResult> { - this._assertInitialized("readServerResource"); - return await this.request({ method: "resources/read", params }, options); - } - - /** - * List available resources from the originating MCP server (proxied through the host). - * - * Apps can list resources to discover what content is available on the MCP server. - * This enables dynamic resource discovery and building resource browsers or pickers. - * The host proxies the request to the actual MCP server and returns the resource list. - * - * Results may be paginated using the `cursor` parameter for servers with many resources. - * - * @param params - Optional parameters (omit for all resources, or `{ cursor }` for pagination) - * @param options - Request options (timeout, etc.) - * @returns List of resources with their URIs, names, descriptions, mimeTypes, and optional pagination cursor - * - * @throws {Error} If the request times out or the connection is lost - * @throws {Error} If the host rejects the request - * - * @example Discover available videos and build a picker UI - * ```ts source="./app.examples.ts#App_listServerResources_buildPicker" - * try { - * const result = await app.listServerResources(); - * const videoResources = result.resources.filter((r) => - * r.mimeType?.startsWith("video/"), - * ); - * videoResources.forEach((resource) => { - * const option = document.createElement("option"); - * option.value = resource.uri; - * option.textContent = resource.description || resource.name; - * selectElement.appendChild(option); - * }); - * } catch (error) { - * console.error("Failed to list resources:", error); - * } - * ``` - * - * @see {@link readServerResource `readServerResource`} to read a specific resource - */ - async listServerResources( - params?: ListResourcesRequest["params"], - options?: RequestOptions, - ): Promise<ListResourcesResult> { - this._assertInitialized("listServerResources"); - return await this.request({ method: "resources/list", params }, options); - } - - /** - * Request an LLM completion from the host (standard MCP `sampling/createMessage`). - * - * Enables the app to use the host's model connection for completions. The host - * has full discretion over which model to select and MAY modify or reject the - * request (human-in-the-loop). Check {@link getHostCapabilities `getHostCapabilities`}`()?.sampling` - * before calling โ€” hosts without this capability will reject the request. - * - * This method reuses the stock MCP `CreateMessageRequest` shape. When `params.tools` - * is provided, the result is parsed with the extended schema that permits - * `stopReason: "toolUse"` and array content containing `tool_use` blocks. - * - * @param params - Standard MCP `CreateMessageRequest` params (messages, maxTokens, - * systemPrompt, temperature, modelPreferences, tools, toolChoice, etc.) - * @param options - Request options (timeout, abort signal) - * @returns `CreateMessageResult` (single content block) or `CreateMessageResultWithTools` - * (array content, may include `tool_use` blocks) depending on whether `tools` was set - * - * @throws {Error} If the host rejects the request or does not support sampling - * @throws {Error} If the request times out or the connection is lost - * - * @example Simple completion - * ```ts source="./app.examples.ts#App_createSamplingMessage_simple" - * const result = await app.createSamplingMessage({ - * messages: [ - * { - * role: "user", - * content: { type: "text", text: "Summarize this in one line." }, - * }, - * ], - * maxTokens: 100, - * }); - * console.log(result.content); - * ``` - * - * @example Agentic loop with tools - * ```ts source="./app.examples.ts#App_createSamplingMessage_withTools" - * if (!app.getHostCapabilities()?.sampling?.tools) return; - * - * const result = await app.createSamplingMessage({ - * messages, - * maxTokens: 1024, - * tools: [ - * { - * name: "get_weather", - * description: "Get the current weather", - * inputSchema: { - * type: "object", - * properties: { city: { type: "string" } }, - * }, - * }, - * ], - * }); - * if (result.stopReason === "toolUse") { - * // result.content may be an array containing tool_use blocks - * } - * ``` - * - * @see `CreateMessageRequest` from @modelcontextprotocol/client for the request type - * @see `CreateMessageResult` / `CreateMessageResultWithTools` from @modelcontextprotocol/client for result types - */ - async createSamplingMessage( - params: CreateMessageRequest["params"] & { tools?: undefined }, - options?: RequestOptions, - ): Promise<CreateMessageResult>; - async createSamplingMessage( - params: CreateMessageRequest["params"], - options?: RequestOptions, - ): Promise<CreateMessageResultWithTools>; - async createSamplingMessage( - params: CreateMessageRequest["params"], - options?: RequestOptions, - ): Promise<CreateMessageResult | CreateMessageResultWithTools> { - this._assertInitialized("createSamplingMessage"); - return await this.request( - { method: "sampling/createMessage", params }, + CallToolResultSchema, options, ); } @@ -1507,50 +765,26 @@ export class App extends Protocol<BaseContext> { * * @param params - Message role and content * @param options - Request options (timeout, etc.) - * @returns Result with optional `isError` flag indicating host rejection + * @returns Result indicating success or error (no message content returned) * - * @throws {Error} If the request times out or the connection is lost + * @throws {Error} If the host rejects the message * * @example Send a text message from user interaction - * ```ts source="./app.examples.ts#App_sendMessage_textFromInteraction" + * ```typescript * try { - * const result = await app.sendMessage({ + * await app.sendMessage({ * role: "user", - * content: [{ type: "text", text: "Show me details for item #42" }], + * content: [{ type: "text", text: "Show me details for item #42" }] * }); - * if (result.isError) { - * console.error("Host rejected the message"); - * // Handle rejection appropriately for your app - * } * } catch (error) { * console.error("Failed to send message:", error); - * // Handle transport/protocol error + * // Handle error appropriately for your app * } * ``` * - * @example Send follow-up message after offloading large data to model context - * ```ts source="./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" }], - * }); - * ``` - * - * @see {@link McpUiMessageRequest `McpUiMessageRequest`} for request structure + * @see {@link McpUiMessageRequest} for request structure */ sendMessage(params: McpUiMessageRequest["params"], options?: RequestOptions) { - this._assertInitialized("sendMessage"); return this.request( <McpUiMessageRequest>{ method: "ui/message", @@ -1570,11 +804,11 @@ export class App extends Protocol<BaseContext> { * @param params - Log level and message * * @example Log app state for debugging - * ```ts source="./app.examples.ts#App_sendLog_debugState" + * ```typescript * app.sendLog({ * level: "info", * data: "Weather data refreshed", - * logger: "WeatherApp", + * logger: "WeatherApp" * }); * ``` * @@ -1590,52 +824,31 @@ export class App extends Protocol<BaseContext> { /** * Update the host's model context with app state. * - * Context updates are intended to be available to the model in future - * turns, without triggering an immediate model response (unlike {@link sendMessage `sendMessage`}). + * Unlike `sendLog`, which is for debugging/telemetry, context updates + * are intended to be available to the model in future reasoning, + * without requiring a follow-up action (like `sendMessage`). * * The host will typically defer sending the context to the model until the - * next user message โ€” either from the actual user or via `sendMessage`. Only - * the last update is sent; each call overwrites any previous context. + * next user message (including `ui/message`), and will only send the last + * update received. Each call overwrites any previous context update. * * @param params - Context content and/or structured content * @param options - Request options (timeout, etc.) * * @throws {Error} If the host rejects the context update (e.g., unsupported content type) - * @throws {Error} If the request times out or the connection is lost * * @example Update model context with current app state - * ```ts source="./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")}`; - * + * ```typescript * await app.updateModelContext({ - * content: [{ type: "text", text: markdown }], + * content: [{ type: "text", text: "User selected 3 items totaling $150.00" }] * }); * ``` * - * @example Report runtime error to model - * ```ts source="./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", - * }, - * ], - * }); - * } + * @example Update with structured content + * ```typescript + * await app.updateModelContext({ + * structuredContent: { selectedItems: 3, total: 150.00, currency: "USD" } + * }); * ``` * * @returns Promise that resolves when the context update is acknowledged @@ -1644,7 +857,6 @@ export class App extends Protocol<BaseContext> { params: McpUiUpdateModelContextRequest["params"], options?: RequestOptions, ) { - this._assertInitialized("updateModelContext"); return this.request( <McpUiUpdateModelContextRequest>{ method: "ui/update-model-context", @@ -1659,29 +871,28 @@ export class App extends Protocol<BaseContext> { * Request the host to open an external URL in the default browser. * * The host may deny this request based on user preferences or security policy. - * Apps should handle rejection gracefully by checking `result.isError`. + * Apps should handle rejection gracefully. * * @param params - URL to open * @param options - Request options (timeout, etc.) - * @returns Result with `isError: true` if the host denied the request (e.g., blocked domain, user cancelled) + * @returns Result indicating success or error * + * @throws {Error} If the host denies the request (e.g., blocked domain, user cancelled) * @throws {Error} If the request times out or the connection is lost * * @example Open documentation link - * ```ts source="./app.examples.ts#App_openLink_documentation" - * const { isError } = await app.openLink({ url: "https://docs.example.com" }); - * if (isError) { - * // Host denied the request (e.g., blocked domain, user cancelled) + * ```typescript + * try { + * await app.openLink({ url: "https://docs.example.com" }); + * } catch (error) { + * console.error("Failed to open link:", error); * // Optionally show fallback: display URL for manual copy - * console.warn("Link request denied"); * } * ``` * - * @see {@link McpUiOpenLinkRequest `McpUiOpenLinkRequest`} for request structure - * @see {@link McpUiOpenLinkResult `McpUiOpenLinkResult`} for result structure + * @see {@link McpUiOpenLinkRequest} for request structure */ openLink(params: McpUiOpenLinkRequest["params"], options?: RequestOptions) { - this._assertInitialized("openLink"); return this.request( <McpUiOpenLinkRequest>{ method: "ui/open-link", @@ -1692,131 +903,9 @@ export class App extends Protocol<BaseContext> { ); } - /** @deprecated Use {@link openLink `openLink`} instead */ + /** @deprecated Use {@link openLink} instead */ sendOpenLink: App["openLink"] = this.openLink; - /** - * Request the host to download a file. - * - * Since MCP Apps run in sandboxed iframes where direct downloads are blocked, - * this provides a host-mediated mechanism for file exports. The host will - * typically show a confirmation dialog before initiating the download. - * - * Uses standard MCP resource types: `EmbeddedResource` for inline content - * and `ResourceLink` for content the host can fetch directly. - * - * @param params - Resource contents to download - * @param options - Request options (timeout, etc.) - * @returns Result with `isError: true` if the host denied the request (e.g., user cancelled) - * - * @throws {Error} If the request times out or the connection is lost - * - * @example Download a JSON file (embedded text resource) - * ```ts - * const data = JSON.stringify({ items: selectedItems }, null, 2); - * const { isError } = await app.downloadFile({ - * contents: [{ - * type: "resource", - * resource: { - * uri: "file:///export.json", - * mimeType: "application/json", - * text: data, - * }, - * }], - * }); - * if (isError) { - * console.warn("Download denied or cancelled"); - * } - * ``` - * - * @example Download binary content (embedded blob resource) - * ```ts - * const { isError } = await app.downloadFile({ - * contents: [{ - * type: "resource", - * resource: { - * uri: "file:///image.png", - * mimeType: "image/png", - * blob: base64EncodedPng, - * }, - * }], - * }); - * ``` - * - * @example Download via resource link (host fetches) - * ```ts - * const { isError } = await app.downloadFile({ - * contents: [{ - * type: "resource_link", - * uri: "https://api.example.com/reports/q4.pdf", - * name: "Q4 Report", - * mimeType: "application/pdf", - * }], - * }); - * ``` - * - * @see {@link McpUiDownloadFileRequest `McpUiDownloadFileRequest`} for request structure - * @see {@link McpUiDownloadFileResult `McpUiDownloadFileResult`} for result structure - */ - downloadFile( - params: McpUiDownloadFileRequest["params"], - options?: RequestOptions, - ) { - this._assertInitialized("downloadFile"); - return this.request( - <McpUiDownloadFileRequest>{ - method: "ui/download-file", - params, - }, - McpUiDownloadFileResultSchema, - options, - ); - } - - /** - * Request the host to tear down this app. - * - * Apps call this method to request that the host tear them down. The host - * decides whether to proceed - if approved, the host will send - * `ui/resource-teardown` to allow the app to perform gracefull termination before being - * unmounted. This piggybacks on the existing teardown mechanism, ensuring - * the app only needs a single shutdown procedure (via {@link onteardown `onteardown`}) - * regardless of whether the teardown was initiated by the app or the host. - * - * This is a fire-and-forget notification - no response is expected. - * If the host approves, the app will receive a `ui/resource-teardown` - * request via the {@link onteardown `onteardown`} handler to persist unsaved state. - * - * @param params - Empty params object (reserved for future use) - * @returns Promise that resolves when the notification is sent - * - * @example App-initiated teardown after user action - * ```typescript - * // User clicks "Done" button in the app - * async function handleDoneClick() { - * // Request the host to tear down the app - * await app.requestTeardown(); - * // If host approves, onteardown handler will be called for termination - * } - * - * // Set up teardown handler (called for both app-initiated and host-initiated teardown) - * app.onteardown = async () => { - * await saveState(); - * closeConnections(); - * return {}; - * }; - * ``` - * - * @see {@link McpUiRequestTeardownNotification `McpUiRequestTeardownNotification`} for notification structure - * @see {@link onteardown `onteardown`} for the graceful termination handler - */ - requestTeardown(params: McpUiRequestTeardownNotification["params"] = {}) { - return this.notification(<McpUiRequestTeardownNotification>{ - method: "ui/notifications/request-teardown", - params, - }); - } - /** * Request a change to the display mode. * @@ -1829,25 +918,22 @@ export class App extends Protocol<BaseContext> { * @param options - Request options (timeout, etc.) * @returns Result containing the actual display mode that was set * - * @example Toggle display mode - * ```ts source="./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"); + * @example Request fullscreen mode + * ```typescript + * const context = app.getHostContext(); + * if (context?.availableDisplayModes?.includes("fullscreen")) { + * const result = await app.requestDisplayMode({ mode: "fullscreen" }); + * console.log("Display mode set to:", result.mode); * } * ``` * - * @see {@link McpUiRequestDisplayModeRequest `McpUiRequestDisplayModeRequest`} for request structure - * @see {@link McpUiHostContext `McpUiHostContext`} for checking availableDisplayModes + * @see {@link McpUiRequestDisplayModeRequest} for request structure + * @see {@link McpUiHostContext} for checking availableDisplayModes */ requestDisplayMode( params: McpUiRequestDisplayModeRequest["params"], options?: RequestOptions, ) { - this._assertInitialized("requestDisplayMode"); return this.request( <McpUiRequestDisplayModeRequest>{ method: "ui/request-display-mode", @@ -1867,16 +953,16 @@ export class App extends Protocol<BaseContext> { * @param params - New width and height in pixels * * @example Manually notify host of size change - * ```ts source="./app.examples.ts#App_sendSizeChanged_manual" + * ```typescript * app.sendSizeChanged({ * width: 400, - * height: 600, + * height: 600 * }); * ``` * * @returns Promise that resolves when the notification is sent * - * @see {@link McpUiSizeChangedNotification `McpUiSizeChangedNotification`} for notification structure + * @see {@link McpUiSizeChangedNotification} for notification structure */ sendSizeChanged(params: McpUiSizeChangedNotification["params"]) { return this.notification(<McpUiSizeChangedNotification>{ @@ -1899,12 +985,8 @@ export class App extends Protocol<BaseContext> { * @returns Cleanup function to disconnect the observer * * @example Manual setup for custom scenarios - * ```ts source="./app.examples.ts#App_setupAutoResize_manual" - * const app = new App( - * { name: "MyApp", version: "1.0.0" }, - * {}, - * { autoResize: false }, - * ); + * ```typescript + * const app = new App(appInfo, capabilities, { autoResize: false }); * await app.connect(transport); * * // Later, enable auto-resize manually @@ -1928,22 +1010,23 @@ export class App extends Protocol<BaseContext> { scheduled = false; const html = document.documentElement; - // Measure actual content height by temporarily overriding html sizing. - // Height uses max-content because fit-content would clamp to the viewport - // height when content is taller than the iframe, causing internal scrolling. - // - // Width uses window.innerWidth instead of measuring via fit-content. - // Setting html.style.width to fit-content forces a synchronous reflow at - // 0px width for responsive apps (whose content derives width from the - // container rather than having intrinsic width). This causes the browser - // to clamp scrollLeft on any horizontal scroll containers to 0, permanently - // destroying their scroll positions. + // Measure actual content size by temporarily setting html to fit-content. + // This shrinks html to fit body (including body margins), giving us the + // true minimum size needed by the content. + const originalWidth = html.style.width; const originalHeight = html.style.height; - html.style.height = "max-content"; - const height = Math.ceil(html.getBoundingClientRect().height); + html.style.width = "fit-content"; + html.style.height = "fit-content"; + const rect = html.getBoundingClientRect(); + html.style.width = originalWidth; html.style.height = originalHeight; - const width = Math.ceil(window.innerWidth); + // Compensate for scrollbar width on Linux/Windows where scrollbars consume space. + // On systems with overlay scrollbars (macOS), this will be 0. + const scrollbarWidth = window.innerWidth - html.clientWidth; + + const width = Math.ceil(rect.width + scrollbarWidth); + const height = Math.ceil(rect.height); // Only send if size actually changed (prevents feedback loops from style changes) if (width !== lastWidth || height !== lastHeight) { @@ -1972,19 +1055,22 @@ export class App extends Protocol<BaseContext> { * 2. Sends `ui/initialize` request with app info and capabilities * 3. Receives host capabilities and context in response * 4. Sends `ui/notifications/initialized` notification - * 5. Sets up auto-resize using {@link setupSizeChangedNotifications `setupSizeChangedNotifications`} if enabled (default) + * 5. Sets up auto-resize using {@link setupSizeChangedNotifications} if enabled (default) * * If initialization fails, the connection is automatically closed and an error * is thrown. * - * @param transport - Transport layer (typically {@link PostMessageTransport `PostMessageTransport`}) + * @param transport - Transport layer (typically {@link PostMessageTransport}) * @param options - Request options for the initialize request * * @throws {Error} If initialization fails or connection is lost * * @example Connect with PostMessageTransport - * ```ts source="./app.examples.ts#App_connect_withPostMessageTransport" - * const app = new App({ name: "MyApp", version: "1.0.0" }, {}); + * ```typescript + * const app = new App( + * { name: "MyApp", version: "1.0.0" }, + * {} + * ); * * try { * await app.connect(new PostMessageTransport(window.parent, window.parent)); @@ -1994,9 +1080,9 @@ export class App extends Protocol<BaseContext> { * } * ``` * - * @see {@link McpUiInitializeRequest `McpUiInitializeRequest`} for the initialization request structure - * @see {@link McpUiInitializedNotification `McpUiInitializedNotification`} for the initialized notification - * @see {@link PostMessageTransport `PostMessageTransport`} for the typical transport implementation + * @see {@link McpUiInitializeRequest} for the initialization request structure + * @see {@link McpUiInitializedNotification} for the initialized notification + * @see {@link PostMessageTransport} for the typical transport implementation */ override async connect( transport: Transport = new PostMessageTransport( @@ -2005,24 +1091,14 @@ export class App extends Protocol<BaseContext> { ), options?: RequestOptions, ): Promise<void> { - if (this.transport) { - throw new Error( - "App is already connected. Call close() before connecting again.", - ); - } - this._initializedSent = false; + await super.connect(transport); try { - // Protocol.connect only wires the transport โ€” no MCP handshake. The - // ui/initialize request below is the sole negotiation on this channel, - // matching the v1 wire behavior deployed hosts expect. - await super.connect(transport); - const result = await this.request( <McpUiInitializeRequest>{ method: "ui/initialize", params: { - appCapabilities: this._appCapabilities, + appCapabilities: this._capabilities, appInfo: this._appInfo, protocolVersion: LATEST_PROTOCOL_VERSION, }, @@ -2042,7 +1118,6 @@ export class App extends Protocol<BaseContext> { await this.notification(<McpUiInitializedNotification>{ method: "ui/notifications/initialized", }); - this._initializedSent = true; if (this.options?.autoResize) { this.setupSizeChangedNotifications(); diff --git a/src/constants.ts b/src/constants.ts deleted file mode 100644 index c879a03..0000000 --- a/src/constants.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Metadata key for associating a UI resource URI with a tool. - * - * MCP servers include this key in tool definition metadata (via `tools/list`) - * to indicate which UI resource should be displayed when the tool is called. - * When hosts see a tool with this metadata, they fetch and render the - * corresponding {@link app!App `App`}. - * - * **Note**: This constant is provided for reference and backwards compatibility. - * Server developers should use {@link server-helpers!registerAppTool `registerAppTool`} - * with the `_meta.ui.resourceUri` format instead. Host developers must check both - * formats for compatibility. - * - * @example Modern format (server-side, not in Apps) - * ```ts source="./app.examples.ts#RESOURCE_URI_META_KEY_modernFormat" - * // Preferred: Use registerAppTool with nested ui.resourceUri - * registerAppTool( - * server, - * "weather", - * { - * description: "Get weather forecast", - * _meta: { - * ui: { resourceUri: "ui://weather/forecast" }, - * }, - * }, - * handler, - * ); - * ``` - * - * @example Legacy format (deprecated, for backwards compatibility) - * ```ts source="./app.examples.ts#RESOURCE_URI_META_KEY_legacyFormat" - * // Deprecated: Direct use of RESOURCE_URI_META_KEY - * server.registerTool( - * "weather", - * { - * description: "Get weather forecast", - * _meta: { - * [RESOURCE_URI_META_KEY]: "ui://weather/forecast", - * }, - * }, - * handler, - * ); - * ``` - * - * @example How hosts check for this metadata (must support both formats) - * ```ts source="./app.examples.ts#RESOURCE_URI_META_KEY_hostSide" - * // Hosts should check both modern and legacy formats - * const meta = tool._meta; - * const uiMeta = meta?.ui as McpUiToolMeta | undefined; - * const legacyUri = meta?.[RESOURCE_URI_META_KEY] as string | undefined; - * const uiUri = uiMeta?.resourceUri ?? legacyUri; - * if (typeof uiUri === "string" && uiUri.startsWith("ui://")) { - * // Fetch the resource and display the UI - * } - * ``` - */ -export const RESOURCE_URI_META_KEY = "ui/resourceUri"; - -/** - * MIME type for MCP UI resources. - * - * Identifies HTML content as an MCP App UI resource. - * - * Used by {@link server-helpers!registerAppResource `registerAppResource`} as the default MIME type for app resources. - */ -export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; diff --git a/src/events.test.ts b/src/events.test.ts deleted file mode 100644 index 60e7c35..0000000 --- a/src/events.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it, spyOn } from "bun:test"; - -import { EventDispatcher } from "./events.js"; - -type TestEventMap = { - change: { value: number }; -}; - -describe("EventDispatcher", () => { - it("replaces the singular handler", () => { - const dispatcher = new EventDispatcher<TestEventMap>(); - const calls: string[] = []; - const first = () => calls.push("first"); - const second = () => calls.push("second"); - const warn = spyOn(console, "warn").mockImplementation(() => {}); - - try { - dispatcher.setHandler("change", first); - dispatcher.setHandler("change", second); - - expect(dispatcher.getHandler("change")).toBe(second); - dispatcher.dispatch("change", { value: 1 }); - expect(calls).toEqual(["second"]); - } finally { - warn.mockRestore(); - } - }); - - it("dispatches the singular handler before listeners in insertion order", () => { - const dispatcher = new EventDispatcher<TestEventMap>(); - const calls: string[] = []; - - dispatcher.setHandler("change", () => calls.push("handler")); - dispatcher.addEventListener("change", () => calls.push("listener-1")); - dispatcher.addEventListener("change", () => calls.push("listener-2")); - - dispatcher.dispatch("change", { value: 1 }); - - expect(calls).toEqual(["handler", "listener-1", "listener-2"]); - }); - - it("uses a listener snapshot when a listener is removed during dispatch", () => { - const dispatcher = new EventDispatcher<TestEventMap>(); - const calls: string[] = []; - const second = () => calls.push("second"); - const first = () => { - calls.push("first"); - dispatcher.removeEventListener("change", second); - }; - - dispatcher.addEventListener("change", first); - dispatcher.addEventListener("change", second); - - dispatcher.dispatch("change", { value: 1 }); - dispatcher.dispatch("change", { value: 2 }); - - expect(calls).toEqual(["first", "second", "first"]); - }); - - it("warns only when one singular handler replaces another", () => { - const dispatcher = new EventDispatcher<TestEventMap>(); - const warn = spyOn(console, "warn").mockImplementation(() => {}); - const first = () => {}; - const second = () => {}; - - try { - dispatcher.setHandler("change", first); - dispatcher.setHandler("change", undefined); - dispatcher.setHandler("change", first); - expect(warn).not.toHaveBeenCalled(); - - dispatcher.setHandler("change", second); - - expect(warn).toHaveBeenCalledTimes(1); - expect(String(warn.mock.calls[0]?.[0])).toContain( - "onchange handler replaced", - ); - } finally { - warn.mockRestore(); - } - }); -}); diff --git a/src/events.ts b/src/events.ts deleted file mode 100644 index aa2e862..0000000 --- a/src/events.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Per-event state: a singular `on*` handler (replace semantics) plus an - * ordered listener array (`addEventListener` semantics). - */ -interface EventSlot<T = unknown> { - onHandler?: ((params: T) => void) | undefined; - listeners: ((params: T) => void)[]; -} - -/** - * Protocol-independent DOM-style event fan-out. - * - * Owners perform event-specific state updates before calling - * {@link dispatch}. Dispatch then invokes the singular handler followed by a - * snapshot of listeners in insertion order. - * - * @typeParam EventMap - Maps event names to their listener parameter types. - */ -export class EventDispatcher<EventMap extends Record<string, unknown>> { - private readonly _eventSlots = new Map<keyof EventMap, EventSlot>(); - - /** Set, replace, or clear the singular handler for an event. */ - setHandler<K extends keyof EventMap>( - event: K, - handler: ((params: EventMap[K]) => void) | undefined, - ): void { - const slot = this._ensureEventSlot(event); - if (slot.onHandler && handler) { - console.warn( - `[MCP Apps] on${String(event)} handler replaced. ` + - `Use addEventListener("${String(event)}", โ€ฆ) to add multiple listeners without replacing.`, - ); - } - slot.onHandler = handler; - } - - /** Get the singular handler for an event, if one is set. */ - getHandler<K extends keyof EventMap>( - event: K, - ): ((params: EventMap[K]) => void) | undefined { - return (this._eventSlots.get(event) as EventSlot<EventMap[K]> | undefined) - ?.onHandler; - } - - /** Add a listener. Listeners run in insertion order after the handler. */ - addEventListener<K extends keyof EventMap>( - event: K, - handler: (params: EventMap[K]) => void, - ): void { - this._ensureEventSlot(event).listeners.push(handler); - } - - /** Remove the first matching listener, if present. */ - removeEventListener<K extends keyof EventMap>( - event: K, - handler: (params: EventMap[K]) => void, - ): void { - const slot = this._eventSlots.get(event) as - | EventSlot<EventMap[K]> - | undefined; - if (!slot) return; - const idx = slot.listeners.indexOf(handler); - if (idx !== -1) slot.listeners.splice(idx, 1); - } - - /** - * Dispatch an event to its singular handler and a listener snapshot. - * Listener mutations during dispatch take effect on the next dispatch. - */ - dispatch<K extends keyof EventMap>(event: K, params: EventMap[K]): void { - const slot = this._eventSlots.get(event) as - | EventSlot<EventMap[K]> - | undefined; - if (!slot) return; - slot.onHandler?.(params); - for (const l of [...slot.listeners]) l(params); - } - - private _ensureEventSlot<K extends keyof EventMap>( - event: K, - ): EventSlot<EventMap[K]> { - let slot = this._eventSlots.get(event) as - | EventSlot<EventMap[K]> - | undefined; - if (!slot) { - slot = { listeners: [] }; - this._eventSlots.set(event, slot as EventSlot); - } - return slot; - } -} - -/** - * Tracks which JSON-RPC methods already have a handler registered through the - * owning `App` / `AppBridge`, so a second direct `setRequestHandler` / - * `setNotificationHandler` call for the same method throws instead of - * silently replacing the first handler (the base SDK `Protocol` replaces). - * - * The `on*` setters use {@link replace} for DOM-style replace semantics, and - * `removeRequestHandler` / `removeNotificationHandler` use {@link release}. - * - * @internal - */ -export class MethodRegistry { - private readonly _methods = new Set<string>(); - - /** Claim `method`. Throws if a handler is already registered for it. */ - claim(method: string, via: string): void { - if (this._methods.has(method)) { - throw new Error( - `Handler for "${method}" already registered (via ${via}). ` + - `Use addEventListener() to attach multiple listeners, ` + - `or the on* setter for replace semantics.`, - ); - } - this._methods.add(method); - } - - /** Claim `method` with replace semantics (never throws). */ - replace(method: string): void { - this._methods.add(method); - } - - /** Release `method` so it can be claimed again. */ - release(method: string): void { - this._methods.delete(method); - } - - has(method: string): boolean { - return this._methods.has(method); - } -} diff --git a/src/generated/schema.json b/src/generated/schema.json index 246ed7d..e17767d 100644 --- a/src/generated/schema.json +++ b/src/generated/schema.json @@ -9,19 +9,10 @@ "type": "object", "properties": { "experimental": { - "description": "Experimental features keyed by identifier.", + "description": "Experimental features (structure TBD).", "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {}, - "description": "Experimental features keyed by identifier." - } + "properties": {}, + "additionalProperties": false }, "tools": { "description": "App exposes MCP-style tools that the host can call.", @@ -33,41 +24,6 @@ } }, "additionalProperties": false - }, - "availableDisplayModes": { - "description": "Display modes the app supports.", - "type": "array", - "items": { - "anyOf": [ - { - "type": "string", - "const": "inline" - }, - { - "type": "string", - "const": "fullscreen" - }, - { - "type": "string", - "const": "pip" - } - ], - "description": "Display mode for UI presentation." - } - } - }, - "additionalProperties": false - }, - "McpUiClientCapabilities": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "mimeTypes": { - "description": "Array of supported MIME types for UI resources.\nMust include `\"text/html;profile=mcp-app\"` for MCP Apps support.", - "type": "array", - "items": { - "type": "string" - } } }, "additionalProperties": false @@ -90,245 +46,18 @@ ], "description": "Display mode for UI presentation." }, - "McpUiDownloadFileRequest": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "method": { - "type": "string", - "const": "ui/download-file" - }, - "params": { - "type": "object", - "properties": { - "contents": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "resource" - }, - "resource": { - "anyOf": [ - { - "type": "object", - "properties": { - "uri": { - "type": "string" - }, - "mimeType": { - "type": "string" - }, - "_meta": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "text": { - "type": "string" - } - }, - "required": ["uri", "text"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "uri": { - "type": "string" - }, - "mimeType": { - "type": "string" - }, - "_meta": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "blob": { - "type": "string" - } - }, - "required": ["uri", "blob"], - "additionalProperties": false - } - ] - }, - "annotations": { - "type": "object", - "properties": { - "audience": { - "type": "array", - "items": { - "type": "string", - "enum": ["user", "assistant"] - } - }, - "priority": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "lastModified": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" - } - }, - "additionalProperties": false - }, - "_meta": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": ["type", "resource"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "title": { - "type": "string" - }, - "icons": { - "type": "array", - "items": { - "type": "object", - "properties": { - "src": { - "type": "string" - }, - "mimeType": { - "type": "string" - }, - "sizes": { - "type": "array", - "items": { - "type": "string" - } - }, - "theme": { - "type": "string", - "enum": ["light", "dark"] - } - }, - "required": ["src"], - "additionalProperties": false - } - }, - "uri": { - "type": "string" - }, - "description": { - "type": "string" - }, - "mimeType": { - "type": "string" - }, - "size": { - "type": "number" - }, - "annotations": { - "type": "object", - "properties": { - "audience": { - "type": "array", - "items": { - "type": "string", - "enum": ["user", "assistant"] - } - }, - "priority": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "lastModified": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" - } - }, - "additionalProperties": false - }, - "_meta": { - "type": "object", - "properties": {}, - "additionalProperties": {} - }, - "type": { - "type": "string", - "const": "resource_link" - } - }, - "required": ["name", "uri", "type"], - "additionalProperties": false - } - ] - }, - "description": "Resource contents to download โ€” embedded (inline data) or linked (host fetches). Uses standard MCP resource types." - } - }, - "required": ["contents"], - "additionalProperties": false - } - }, - "required": ["method", "params"], - "additionalProperties": false - }, - "McpUiDownloadFileResult": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "isError": { - "description": "True if the download failed (e.g., user cancelled or host denied).", - "type": "boolean" - } - }, - "additionalProperties": {} - }, "McpUiHostCapabilities": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "experimental": { - "description": "Experimental features keyed by identifier.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {}, - "description": "Experimental features keyed by identifier." - } - }, - "openLinks": { - "description": "Host supports opening external URLs.", + "description": "Experimental features (structure TBD).", "type": "object", "properties": {}, "additionalProperties": false }, - "downloadFile": { - "description": "Host supports file downloads via ui/download-file.", + "openLinks": { + "description": "Host supports opening external URLs.", "type": "object", "properties": {}, "additionalProperties": false @@ -370,25 +99,25 @@ "type": "object", "properties": { "camera": { - "description": "Request camera access.\n\nMaps to Permission Policy `camera` feature.", + "description": "Request camera access (Permission Policy `camera` feature).", "type": "object", "properties": {}, "additionalProperties": false }, "microphone": { - "description": "Request microphone access.\n\nMaps to Permission Policy `microphone` feature.", + "description": "Request microphone access (Permission Policy `microphone` feature).", "type": "object", "properties": {}, "additionalProperties": false }, "geolocation": { - "description": "Request geolocation access.\n\nMaps to Permission Policy `geolocation` feature.", + "description": "Request geolocation access (Permission Policy `geolocation` feature).", "type": "object", "properties": {}, "additionalProperties": false }, "clipboardWrite": { - "description": "Request clipboard write access.\n\nMaps to Permission Policy `clipboard-write` feature.", + "description": "Request clipboard write access (Permission Policy `clipboard-write` feature).", "type": "object", "properties": {}, "additionalProperties": false @@ -401,28 +130,28 @@ "type": "object", "properties": { "connectDomains": { - "description": "Origins for network requests (fetch/XHR/WebSocket).\n\n- Maps to CSP `connect-src` directive\n- Empty or omitted โ†’ no network connections (secure default)", + "description": "Origins for network requests (fetch/XHR/WebSocket).", "type": "array", "items": { "type": "string" } }, "resourceDomains": { - "description": "Origins for static resources (images, scripts, stylesheets, fonts, media).\n\n- Maps to CSP `img-src`, `script-src`, `style-src`, `font-src`, `media-src` directives\n- Wildcard subdomains supported: `https://*.example.com`\n- Empty or omitted โ†’ no network resources (secure default)", + "description": "Origins for static resources (scripts, images, styles, fonts).", "type": "array", "items": { "type": "string" } }, "frameDomains": { - "description": "Origins for nested iframes.\n\n- Maps to CSP `frame-src` directive\n- Empty or omitted โ†’ no nested iframes allowed (`frame-src 'none'`)", + "description": "Origins for nested iframes (frame-src directive).", "type": "array", "items": { "type": "string" } }, "baseUriDomains": { - "description": "Allowed base URIs for the document.\n\n- Maps to CSP `base-uri` directive\n- Empty or omitted โ†’ only same origin allowed (`base-uri 'self'`)", + "description": "Allowed base URIs for the document (base-uri directive).", "type": "array", "items": { "type": "string" @@ -478,7 +207,7 @@ "additionalProperties": false }, "message": { - "description": "Host supports receiving content messages (ui/message) from the view.", + "description": "Host supports receiving content messages (ui/message) from the Guest UI.", "type": "object", "properties": { "text": { @@ -519,19 +248,6 @@ } }, "additionalProperties": false - }, - "sampling": { - "description": "Host supports LLM sampling (sampling/createMessage) from the view.\nMirrors the MCP `ClientCapabilities.sampling` shape so hosts can pass it through.", - "type": "object", - "properties": { - "tools": { - "description": "Host supports tool use via `tools` and `toolChoice` parameters.", - "type": "object", - "properties": {}, - "additionalProperties": false - } - }, - "additionalProperties": false } }, "additionalProperties": false @@ -614,9 +330,7 @@ "propertyNames": { "type": "string" }, - "additionalProperties": { - "$ref": "#/$defs/__schema0" - } + "additionalProperties": {} }, "required": { "type": "array", @@ -631,10 +345,25 @@ "outputSchema": { "type": "object", "properties": { - "$schema": { - "type": "string" + "type": { + "type": "string", + "const": "object" + }, + "properties": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "required": { + "type": "array", + "items": { + "type": "string" + } } }, + "required": ["type"], "additionalProperties": {} }, "annotations": { @@ -786,6 +515,10 @@ "type": "string", "const": "--color-text-disabled" }, + { + "type": "string", + "const": "--color-text-ghost" + }, { "type": "string", "const": "--color-border-primary" @@ -1135,21 +868,7 @@ "description": "Display modes the host supports.", "type": "array", "items": { - "anyOf": [ - { - "type": "string", - "const": "inline" - }, - { - "type": "string", - "const": "fullscreen" - }, - { - "type": "string", - "const": "pip" - } - ], - "description": "Display mode for UI presentation." + "type": "string" } }, "containerDimensions": { @@ -1291,40 +1010,7 @@ } }, "required": ["method", "params"], - "additionalProperties": false, - "$defs": { - "__schema0": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - }, - { - "type": "null" - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/$defs/__schema0" - } - }, - { - "type": "array", - "items": { - "$ref": "#/$defs/__schema0" - } - } - ] - } - } + "additionalProperties": false }, "McpUiHostContext": { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -1397,9 +1083,7 @@ "propertyNames": { "type": "string" }, - "additionalProperties": { - "$ref": "#/$defs/__schema0" - } + "additionalProperties": {} }, "required": { "type": "array", @@ -1414,10 +1098,25 @@ "outputSchema": { "type": "object", "properties": { - "$schema": { - "type": "string" + "type": { + "type": "string", + "const": "object" + }, + "properties": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "required": { + "type": "array", + "items": { + "type": "string" + } } }, + "required": ["type"], "additionalProperties": {} }, "annotations": { @@ -1569,6 +1268,10 @@ "type": "string", "const": "--color-text-disabled" }, + { + "type": "string", + "const": "--color-text-ghost" + }, { "type": "string", "const": "--color-border-primary" @@ -1918,21 +1621,7 @@ "description": "Display modes the host supports.", "type": "array", "items": { - "anyOf": [ - { - "type": "string", - "const": "inline" - }, - { - "type": "string", - "const": "fullscreen" - }, - { - "type": "string", - "const": "pip" - } - ], - "description": "Display mode for UI presentation." + "type": "string" } }, "containerDimensions": { @@ -2069,40 +1758,7 @@ "additionalProperties": false } }, - "additionalProperties": {}, - "$defs": { - "__schema0": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - }, - { - "type": "null" - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/$defs/__schema0" - } - }, - { - "type": "array", - "items": { - "$ref": "#/$defs/__schema0" - } - } - ] - } - } + "additionalProperties": {} }, "McpUiHostCss": { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -2203,6 +1859,10 @@ "type": "string", "const": "--color-text-disabled" }, + { + "type": "string", + "const": "--color-text-ghost" + }, { "type": "string", "const": "--color-border-primary" @@ -2595,19 +2255,10 @@ "type": "object", "properties": { "experimental": { - "description": "Experimental features keyed by identifier.", + "description": "Experimental features (structure TBD).", "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {}, - "description": "Experimental features keyed by identifier." - } + "properties": {}, + "additionalProperties": false }, "tools": { "description": "App exposes MCP-style tools that the host can call.", @@ -2619,27 +2270,6 @@ } }, "additionalProperties": false - }, - "availableDisplayModes": { - "description": "Display modes the app supports.", - "type": "array", - "items": { - "anyOf": [ - { - "type": "string", - "const": "inline" - }, - { - "type": "string", - "const": "fullscreen" - }, - { - "type": "string", - "const": "pip" - } - ], - "description": "Display mode for UI presentation." - } } }, "additionalProperties": false, @@ -2718,28 +2348,13 @@ "type": "object", "properties": { "experimental": { - "description": "Experimental features keyed by identifier.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {}, - "description": "Experimental features keyed by identifier." - } - }, - "openLinks": { - "description": "Host supports opening external URLs.", + "description": "Experimental features (structure TBD).", "type": "object", "properties": {}, "additionalProperties": false }, - "downloadFile": { - "description": "Host supports file downloads via ui/download-file.", + "openLinks": { + "description": "Host supports opening external URLs.", "type": "object", "properties": {}, "additionalProperties": false @@ -2781,25 +2396,25 @@ "type": "object", "properties": { "camera": { - "description": "Request camera access.\n\nMaps to Permission Policy `camera` feature.", + "description": "Request camera access (Permission Policy `camera` feature).", "type": "object", "properties": {}, "additionalProperties": false }, "microphone": { - "description": "Request microphone access.\n\nMaps to Permission Policy `microphone` feature.", + "description": "Request microphone access (Permission Policy `microphone` feature).", "type": "object", "properties": {}, "additionalProperties": false }, "geolocation": { - "description": "Request geolocation access.\n\nMaps to Permission Policy `geolocation` feature.", + "description": "Request geolocation access (Permission Policy `geolocation` feature).", "type": "object", "properties": {}, "additionalProperties": false }, "clipboardWrite": { - "description": "Request clipboard write access.\n\nMaps to Permission Policy `clipboard-write` feature.", + "description": "Request clipboard write access (Permission Policy `clipboard-write` feature).", "type": "object", "properties": {}, "additionalProperties": false @@ -2812,28 +2427,28 @@ "type": "object", "properties": { "connectDomains": { - "description": "Origins for network requests (fetch/XHR/WebSocket).\n\n- Maps to CSP `connect-src` directive\n- Empty or omitted โ†’ no network connections (secure default)", + "description": "Origins for network requests (fetch/XHR/WebSocket).", "type": "array", "items": { "type": "string" } }, "resourceDomains": { - "description": "Origins for static resources (images, scripts, stylesheets, fonts, media).\n\n- Maps to CSP `img-src`, `script-src`, `style-src`, `font-src`, `media-src` directives\n- Wildcard subdomains supported: `https://*.example.com`\n- Empty or omitted โ†’ no network resources (secure default)", + "description": "Origins for static resources (scripts, images, styles, fonts).", "type": "array", "items": { "type": "string" } }, "frameDomains": { - "description": "Origins for nested iframes.\n\n- Maps to CSP `frame-src` directive\n- Empty or omitted โ†’ no nested iframes allowed (`frame-src 'none'`)", + "description": "Origins for nested iframes (frame-src directive).", "type": "array", "items": { "type": "string" } }, "baseUriDomains": { - "description": "Allowed base URIs for the document.\n\n- Maps to CSP `base-uri` directive\n- Empty or omitted โ†’ only same origin allowed (`base-uri 'self'`)", + "description": "Allowed base URIs for the document (base-uri directive).", "type": "array", "items": { "type": "string" @@ -2889,7 +2504,7 @@ "additionalProperties": false }, "message": { - "description": "Host supports receiving content messages (ui/message) from the view.", + "description": "Host supports receiving content messages (ui/message) from the Guest UI.", "type": "object", "properties": { "text": { @@ -2930,19 +2545,6 @@ } }, "additionalProperties": false - }, - "sampling": { - "description": "Host supports LLM sampling (sampling/createMessage) from the view.\nMirrors the MCP `ClientCapabilities.sampling` shape so hosts can pass it through.", - "type": "object", - "properties": { - "tools": { - "description": "Host supports tool use via `tools` and `toolChoice` parameters.", - "type": "object", - "properties": {}, - "additionalProperties": false - } - }, - "additionalProperties": false } }, "additionalProperties": false, @@ -3018,9 +2620,7 @@ "propertyNames": { "type": "string" }, - "additionalProperties": { - "$ref": "#/$defs/__schema0" - } + "additionalProperties": {} }, "required": { "type": "array", @@ -3035,10 +2635,25 @@ "outputSchema": { "type": "object", "properties": { - "$schema": { - "type": "string" + "type": { + "type": "string", + "const": "object" + }, + "properties": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "required": { + "type": "array", + "items": { + "type": "string" + } } }, + "required": ["type"], "additionalProperties": {} }, "annotations": { @@ -3190,6 +2805,10 @@ "type": "string", "const": "--color-text-disabled" }, + { + "type": "string", + "const": "--color-text-ghost" + }, { "type": "string", "const": "--color-border-primary" @@ -3539,21 +3158,7 @@ "description": "Display modes the host supports.", "type": "array", "items": { - "anyOf": [ - { - "type": "string", - "const": "inline" - }, - { - "type": "string", - "const": "fullscreen" - }, - { - "type": "string", - "const": "pip" - } - ], - "description": "Display mode for UI presentation." + "type": "string" } }, "containerDimensions": { @@ -3700,40 +3305,7 @@ "hostCapabilities", "hostContext" ], - "additionalProperties": {}, - "$defs": { - "__schema0": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - }, - { - "type": "null" - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/$defs/__schema0" - } - }, - { - "type": "array", - "items": { - "$ref": "#/$defs/__schema0" - } - } - ] - } - } + "additionalProperties": {} }, "McpUiInitializedNotification": { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -3954,9 +3526,6 @@ "mimeType": { "type": "string" }, - "size": { - "type": "number" - }, "annotations": { "type": "object", "properties": { @@ -4202,50 +3771,33 @@ "required": ["mode"], "additionalProperties": {} }, - "McpUiRequestTeardownNotification": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "method": { - "type": "string", - "const": "ui/notifications/request-teardown" - }, - "params": { - "type": "object", - "properties": {}, - "additionalProperties": false - } - }, - "required": ["method"], - "additionalProperties": false - }, "McpUiResourceCsp": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "connectDomains": { - "description": "Origins for network requests (fetch/XHR/WebSocket).\n\n- Maps to CSP `connect-src` directive\n- Empty or omitted โ†’ no network connections (secure default)", + "description": "Origins for network requests (fetch/XHR/WebSocket).", "type": "array", "items": { "type": "string" } }, "resourceDomains": { - "description": "Origins for static resources (images, scripts, stylesheets, fonts, media).\n\n- Maps to CSP `img-src`, `script-src`, `style-src`, `font-src`, `media-src` directives\n- Wildcard subdomains supported: `https://*.example.com`\n- Empty or omitted โ†’ no network resources (secure default)", + "description": "Origins for static resources (scripts, images, styles, fonts).", "type": "array", "items": { "type": "string" } }, "frameDomains": { - "description": "Origins for nested iframes.\n\n- Maps to CSP `frame-src` directive\n- Empty or omitted โ†’ no nested iframes allowed (`frame-src 'none'`)", + "description": "Origins for nested iframes (frame-src directive).", "type": "array", "items": { "type": "string" } }, "baseUriDomains": { - "description": "Allowed base URIs for the document.\n\n- Maps to CSP `base-uri` directive\n- Empty or omitted โ†’ only same origin allowed (`base-uri 'self'`)", + "description": "Allowed base URIs for the document (base-uri directive).", "type": "array", "items": { "type": "string" @@ -4259,32 +3811,32 @@ "type": "object", "properties": { "csp": { - "description": "Content Security Policy configuration for UI resources.", + "description": "Content Security Policy configuration.", "type": "object", "properties": { "connectDomains": { - "description": "Origins for network requests (fetch/XHR/WebSocket).\n\n- Maps to CSP `connect-src` directive\n- Empty or omitted โ†’ no network connections (secure default)", + "description": "Origins for network requests (fetch/XHR/WebSocket).", "type": "array", "items": { "type": "string" } }, "resourceDomains": { - "description": "Origins for static resources (images, scripts, stylesheets, fonts, media).\n\n- Maps to CSP `img-src`, `script-src`, `style-src`, `font-src`, `media-src` directives\n- Wildcard subdomains supported: `https://*.example.com`\n- Empty or omitted โ†’ no network resources (secure default)", + "description": "Origins for static resources (scripts, images, styles, fonts).", "type": "array", "items": { "type": "string" } }, "frameDomains": { - "description": "Origins for nested iframes.\n\n- Maps to CSP `frame-src` directive\n- Empty or omitted โ†’ no nested iframes allowed (`frame-src 'none'`)", + "description": "Origins for nested iframes (frame-src directive).", "type": "array", "items": { "type": "string" } }, "baseUriDomains": { - "description": "Allowed base URIs for the document.\n\n- Maps to CSP `base-uri` directive\n- Empty or omitted โ†’ only same origin allowed (`base-uri 'self'`)", + "description": "Allowed base URIs for the document (base-uri directive).", "type": "array", "items": { "type": "string" @@ -4294,29 +3846,29 @@ "additionalProperties": false }, "permissions": { - "description": "Sandbox permissions requested by the UI resource.", + "description": "Sandbox permissions requested by the UI.", "type": "object", "properties": { "camera": { - "description": "Request camera access.\n\nMaps to Permission Policy `camera` feature.", + "description": "Request camera access (Permission Policy `camera` feature).", "type": "object", "properties": {}, "additionalProperties": false }, "microphone": { - "description": "Request microphone access.\n\nMaps to Permission Policy `microphone` feature.", + "description": "Request microphone access (Permission Policy `microphone` feature).", "type": "object", "properties": {}, "additionalProperties": false }, "geolocation": { - "description": "Request geolocation access.\n\nMaps to Permission Policy `geolocation` feature.", + "description": "Request geolocation access (Permission Policy `geolocation` feature).", "type": "object", "properties": {}, "additionalProperties": false }, "clipboardWrite": { - "description": "Request clipboard write access.\n\nMaps to Permission Policy `clipboard-write` feature.", + "description": "Request clipboard write access (Permission Policy `clipboard-write` feature).", "type": "object", "properties": {}, "additionalProperties": false @@ -4325,11 +3877,11 @@ "additionalProperties": false }, "domain": { - "description": "Dedicated origin for view sandbox.\n\nUseful when views need stable, dedicated origins for OAuth callbacks, CORS policies, or API key allowlists.\n\n**Host-dependent:** The format and validation rules for this field are determined by each host. Servers MUST consult host-specific documentation for the expected domain format. Common patterns include:\n- Hash-based subdomains (e.g., `{hash}.claudemcpcontent.com`)\n- URL-derived subdomains (e.g., `www-example-com.oaiusercontent.com`)\n\nIf omitted, host uses default sandbox origin (typically per-conversation).", + "description": "Dedicated origin for widget sandbox.", "type": "string" }, "prefersBorder": { - "description": "Visual boundary preference - true if view prefers a visible border.\n\nBoolean requesting whether a visible border and background is provided by the host. Specifying an explicit value for this is recommended because hosts' defaults may vary.\n\n- `true`: request visible border + background\n- `false`: request no visible border + background\n- omitted: host decides border", + "description": "Visual boundary preference - true if UI prefers a visible border.", "type": "boolean" } }, @@ -4340,25 +3892,25 @@ "type": "object", "properties": { "camera": { - "description": "Request camera access.\n\nMaps to Permission Policy `camera` feature.", + "description": "Request camera access (Permission Policy `camera` feature).", "type": "object", "properties": {}, "additionalProperties": false }, "microphone": { - "description": "Request microphone access.\n\nMaps to Permission Policy `microphone` feature.", + "description": "Request microphone access (Permission Policy `microphone` feature).", "type": "object", "properties": {}, "additionalProperties": false }, "geolocation": { - "description": "Request geolocation access.\n\nMaps to Permission Policy `geolocation` feature.", + "description": "Request geolocation access (Permission Policy `geolocation` feature).", "type": "object", "properties": {}, "additionalProperties": false }, "clipboardWrite": { - "description": "Request clipboard write access.\n\nMaps to Permission Policy `clipboard-write` feature.", + "description": "Request clipboard write access (Permission Policy `clipboard-write` feature).", "type": "object", "properties": {}, "additionalProperties": false @@ -4432,28 +3984,28 @@ "type": "object", "properties": { "connectDomains": { - "description": "Origins for network requests (fetch/XHR/WebSocket).\n\n- Maps to CSP `connect-src` directive\n- Empty or omitted โ†’ no network connections (secure default)", + "description": "Origins for network requests (fetch/XHR/WebSocket).", "type": "array", "items": { "type": "string" } }, "resourceDomains": { - "description": "Origins for static resources (images, scripts, stylesheets, fonts, media).\n\n- Maps to CSP `img-src`, `script-src`, `style-src`, `font-src`, `media-src` directives\n- Wildcard subdomains supported: `https://*.example.com`\n- Empty or omitted โ†’ no network resources (secure default)", + "description": "Origins for static resources (scripts, images, styles, fonts).", "type": "array", "items": { "type": "string" } }, "frameDomains": { - "description": "Origins for nested iframes.\n\n- Maps to CSP `frame-src` directive\n- Empty or omitted โ†’ no nested iframes allowed (`frame-src 'none'`)", + "description": "Origins for nested iframes (frame-src directive).", "type": "array", "items": { "type": "string" } }, "baseUriDomains": { - "description": "Allowed base URIs for the document.\n\n- Maps to CSP `base-uri` directive\n- Empty or omitted โ†’ only same origin allowed (`base-uri 'self'`)", + "description": "Allowed base URIs for the document (base-uri directive).", "type": "array", "items": { "type": "string" @@ -4467,25 +4019,25 @@ "type": "object", "properties": { "camera": { - "description": "Request camera access.\n\nMaps to Permission Policy `camera` feature.", + "description": "Request camera access (Permission Policy `camera` feature).", "type": "object", "properties": {}, "additionalProperties": false }, "microphone": { - "description": "Request microphone access.\n\nMaps to Permission Policy `microphone` feature.", + "description": "Request microphone access (Permission Policy `microphone` feature).", "type": "object", "properties": {}, "additionalProperties": false }, "geolocation": { - "description": "Request geolocation access.\n\nMaps to Permission Policy `geolocation` feature.", + "description": "Request geolocation access (Permission Policy `geolocation` feature).", "type": "object", "properties": {}, "additionalProperties": false }, "clipboardWrite": { - "description": "Request clipboard write access.\n\nMaps to Permission Policy `clipboard-write` feature.", + "description": "Request clipboard write access (Permission Policy `clipboard-write` feature).", "type": "object", "properties": {}, "additionalProperties": false @@ -4610,6 +4162,10 @@ "type": "string", "const": "--color-text-disabled" }, + { + "type": "string", + "const": "--color-text-ghost" + }, { "type": "string", "const": "--color-border-primary" @@ -4922,6 +4478,10 @@ "type": "string", "const": "--color-text-disabled" }, + { + "type": "string", + "const": "--color-text-ghost" + }, { "type": "string", "const": "--color-border-primary" @@ -5396,12 +4956,6 @@ ], "description": "Tool visibility scope - who can access the tool." } - }, - "csp": { - "not": {} - }, - "permissions": { - "not": {} } }, "additionalProperties": false @@ -5420,52 +4974,26 @@ "_meta": { "type": "object", "properties": { - "io.modelcontextprotocol/serverInfo": { + "progressToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + ] + }, + "io.modelcontextprotocol/related-task": { "type": "object", "properties": { - "name": { - "type": "string" - }, - "title": { - "type": "string" - }, - "icons": { - "type": "array", - "items": { - "type": "object", - "properties": { - "src": { - "type": "string" - }, - "mimeType": { - "type": "string" - }, - "sizes": { - "type": "array", - "items": { - "type": "string" - } - }, - "theme": { - "type": "string", - "enum": ["light", "dark"] - } - }, - "required": ["src"], - "additionalProperties": false - } - }, - "version": { - "type": "string" - }, - "websiteUrl": { - "type": "string" - }, - "description": { + "taskId": { "type": "string" } }, - "required": ["name", "version"], + "required": ["taskId"], "additionalProperties": false } }, @@ -5658,9 +5186,6 @@ "mimeType": { "type": "string" }, - "size": { - "type": "number" - }, "annotations": { "type": "object", "properties": { @@ -5791,7 +5316,13 @@ ] } }, - "structuredContent": {}, + "structuredContent": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, "isError": { "type": "boolean" } @@ -6016,9 +5547,6 @@ "mimeType": { "type": "string" }, - "size": { - "type": "number" - }, "annotations": { "type": "object", "properties": { diff --git a/src/generated/schema.test.ts b/src/generated/schema.test.ts index 57d989f..727c28d 100644 --- a/src/generated/schema.test.ts +++ b/src/generated/schema.test.ts @@ -1,7 +1,7 @@ // Generated by ts-to-zod // Integration tests verifying schemas match TypeScript types // Run: npm run generate:schemas -import { z } from "zod/v4"; +import { z } from "zod"; import * as spec from "../spec.types.js"; import * as generated from "./schema.js"; @@ -35,10 +35,6 @@ export type McpUiOpenLinkResultSchemaInferredType = z.infer< typeof generated.McpUiOpenLinkResultSchema >; -export type McpUiDownloadFileResultSchemaInferredType = z.infer< - typeof generated.McpUiDownloadFileResultSchema ->; - export type McpUiMessageResultSchemaInferredType = z.infer< typeof generated.McpUiMessageResultSchema >; @@ -91,10 +87,6 @@ export type McpUiSupportedContentBlockModalitiesSchemaInferredType = z.infer< typeof generated.McpUiSupportedContentBlockModalitiesSchema >; -export type McpUiRequestTeardownNotificationSchemaInferredType = z.infer< - typeof generated.McpUiRequestTeardownNotificationSchema ->; - export type McpUiHostCapabilitiesSchemaInferredType = z.infer< typeof generated.McpUiHostCapabilitiesSchema >; @@ -127,14 +119,6 @@ export type McpUiToolMetaSchemaInferredType = z.infer< typeof generated.McpUiToolMetaSchema >; -export type McpUiClientCapabilitiesSchemaInferredType = z.infer< - typeof generated.McpUiClientCapabilitiesSchema ->; - -export type McpUiDownloadFileRequestSchemaInferredType = z.infer< - typeof generated.McpUiDownloadFileRequestSchema ->; - export type McpUiMessageRequestSchemaInferredType = z.infer< typeof generated.McpUiMessageRequestSchema >; @@ -191,12 +175,6 @@ expectType<spec.McpUiOpenLinkResult>( expectType<McpUiOpenLinkResultSchemaInferredType>( {} as spec.McpUiOpenLinkResult, ); -expectType<spec.McpUiDownloadFileResult>( - {} as McpUiDownloadFileResultSchemaInferredType, -); -expectType<McpUiDownloadFileResultSchemaInferredType>( - {} as spec.McpUiDownloadFileResult, -); expectType<spec.McpUiMessageResult>({} as McpUiMessageResultSchemaInferredType); expectType<McpUiMessageResultSchemaInferredType>({} as spec.McpUiMessageResult); expectType<spec.McpUiSandboxProxyReadyNotification>( @@ -259,12 +237,6 @@ expectType<spec.McpUiSupportedContentBlockModalities>( expectType<McpUiSupportedContentBlockModalitiesSchemaInferredType>( {} as spec.McpUiSupportedContentBlockModalities, ); -expectType<spec.McpUiRequestTeardownNotification>( - {} as McpUiRequestTeardownNotificationSchemaInferredType, -); -expectType<McpUiRequestTeardownNotificationSchemaInferredType>( - {} as spec.McpUiRequestTeardownNotification, -); expectType<spec.McpUiHostCapabilities>( {} as McpUiHostCapabilitiesSchemaInferredType, ); @@ -305,18 +277,6 @@ expectType<McpUiToolVisibilitySchemaInferredType>( ); expectType<spec.McpUiToolMeta>({} as McpUiToolMetaSchemaInferredType); expectType<McpUiToolMetaSchemaInferredType>({} as spec.McpUiToolMeta); -expectType<spec.McpUiClientCapabilities>( - {} as McpUiClientCapabilitiesSchemaInferredType, -); -expectType<McpUiClientCapabilitiesSchemaInferredType>( - {} as spec.McpUiClientCapabilities, -); -expectType<spec.McpUiDownloadFileRequest>( - {} as McpUiDownloadFileRequestSchemaInferredType, -); -expectType<McpUiDownloadFileRequestSchemaInferredType>( - {} as spec.McpUiDownloadFileRequest, -); expectType<spec.McpUiMessageRequest>( {} as McpUiMessageRequestSchemaInferredType, ); diff --git a/src/generated/schema.ts b/src/generated/schema.ts index 214b227..32277d2 100644 --- a/src/generated/schema.ts +++ b/src/generated/schema.ts @@ -1,16 +1,14 @@ // Generated by ts-to-zod // Post-processed for Zod v3/v4 compatibility and MCP SDK integration // Run: npm run generate:schemas -import { z } from "zod/v4"; +import { z } from "zod"; import { ContentBlockSchema, CallToolResultSchema, - EmbeddedResourceSchema, ImplementationSchema, RequestIdSchema, - ResourceLinkSchema, ToolSchema, -} from "@modelcontextprotocol/core"; +} from "@modelcontextprotocol/sdk/types.js"; /** * @description Color theme preference for the host environment. @@ -51,6 +49,7 @@ export const McpUiStyleVariableKeySchema = z z.literal("--color-text-success"), z.literal("--color-text-warning"), z.literal("--color-text-disabled"), + z.literal("--color-text-ghost"), z.literal("--color-border-primary"), z.literal("--color-border-secondary"), z.literal("--color-border-tertiary"), @@ -136,7 +135,7 @@ export const McpUiStylesSchema = z /** * @description Request to open an external URL in the host's default browser. - * @see {@link app!App.openLink `App.openLink`} for the method that sends this request + * @see {@link app!App.openLink} for the method that sends this request */ export const McpUiOpenLinkRequestSchema = z.object({ method: z.literal("ui/open-link"), @@ -148,7 +147,7 @@ export const McpUiOpenLinkRequestSchema = z.object({ /** * @description Result from opening a URL. - * @see {@link McpUiOpenLinkRequest `McpUiOpenLinkRequest`} + * @see {@link McpUiOpenLinkRequest} */ export const McpUiOpenLinkResultSchema = z .object({ @@ -162,25 +161,9 @@ export const McpUiOpenLinkResultSchema = z }) .passthrough(); -/** - * @description Result from a file download request. - * @see {@link McpUiDownloadFileRequest `McpUiDownloadFileRequest`} - */ -export const McpUiDownloadFileResultSchema = z - .object({ - /** @description True if the download failed (e.g., user cancelled or host denied). */ - isError: z - .boolean() - .optional() - .describe( - "True if the download failed (e.g., user cancelled or host denied).", - ), - }) - .passthrough(); - /** * @description Result from sending a message. - * @see {@link McpUiMessageRequest `McpUiMessageRequest`} + * @see {@link McpUiMessageRequest} */ export const McpUiMessageResultSchema = z .object({ @@ -195,7 +178,7 @@ export const McpUiMessageResultSchema = z /** * @description Notification that the sandbox proxy iframe is ready to receive content. * @internal - * @see https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#sandbox-proxy + * @see https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#sandbox-proxy */ export const McpUiSandboxProxyReadyNotificationSchema = z.object({ method: z.literal("ui/notifications/sandbox-proxy-ready"), @@ -204,143 +187,67 @@ export const McpUiSandboxProxyReadyNotificationSchema = z.object({ /** * @description Content Security Policy configuration for UI resources. - * - * Servers declare which origins their UI requires. Hosts use this to enforce appropriate CSP headers. - * - * > [!IMPORTANT] - * > MCP App HTML runs in a sandboxed iframe with no same-origin server. - * > **All** origins must be declaredโ€”including where your bundled JS/CSS is - * > served from (`localhost` in dev, your CDN in production). */ export const McpUiResourceCspSchema = z.object({ - /** - * @description Origins for network requests (fetch/XHR/WebSocket). - * - * - Maps to CSP `connect-src` directive - * - Empty or omitted โ†’ no network connections (secure default) - * - * @example - * ```ts - * ["https://api.weather.com", "wss://realtime.service.com"] - * ``` - */ + /** @description Origins for network requests (fetch/XHR/WebSocket). */ connectDomains: z .array(z.string()) .optional() - .describe( - "Origins for network requests (fetch/XHR/WebSocket).\n\n- Maps to CSP `connect-src` directive\n- Empty or omitted \u2192 no network connections (secure default)", - ), - /** - * @description Origins for static resources (images, scripts, stylesheets, fonts, media). - * - * - Maps to CSP `img-src`, `script-src`, `style-src`, `font-src`, `media-src` directives - * - Wildcard subdomains supported: `https://*.example.com` - * - Empty or omitted โ†’ no network resources (secure default) - * - * @example - * ```ts - * ["https://cdn.jsdelivr.net", "https://*.cloudflare.com"] - * ``` - */ + .describe("Origins for network requests (fetch/XHR/WebSocket)."), + /** @description Origins for static resources (scripts, images, styles, fonts). */ resourceDomains: z .array(z.string()) .optional() - .describe( - "Origins for static resources (images, scripts, stylesheets, fonts, media).\n\n- Maps to CSP `img-src`, `script-src`, `style-src`, `font-src`, `media-src` directives\n- Wildcard subdomains supported: `https://*.example.com`\n- Empty or omitted \u2192 no network resources (secure default)", - ), - /** - * @description Origins for nested iframes. - * - * - Maps to CSP `frame-src` directive - * - Empty or omitted โ†’ no nested iframes allowed (`frame-src 'none'`) - * - * @example - * ```ts - * ["https://www.youtube.com", "https://player.vimeo.com"] - * ``` - */ + .describe("Origins for static resources (scripts, images, styles, fonts)."), + /** @description Origins for nested iframes (frame-src directive). */ frameDomains: z .array(z.string()) .optional() - .describe( - "Origins for nested iframes.\n\n- Maps to CSP `frame-src` directive\n- Empty or omitted \u2192 no nested iframes allowed (`frame-src 'none'`)", - ), - /** - * @description Allowed base URIs for the document. - * - * - Maps to CSP `base-uri` directive - * - Empty or omitted โ†’ only same origin allowed (`base-uri 'self'`) - * - * @example - * ```ts - * ["https://cdn.example.com"] - * ``` - */ + .describe("Origins for nested iframes (frame-src directive)."), + /** @description Allowed base URIs for the document (base-uri directive). */ baseUriDomains: z .array(z.string()) .optional() - .describe( - "Allowed base URIs for the document.\n\n- Maps to CSP `base-uri` directive\n- Empty or omitted \u2192 only same origin allowed (`base-uri 'self'`)", - ), + .describe("Allowed base URIs for the document (base-uri directive)."), }); /** * @description Sandbox permissions requested by the UI resource. - * - * Servers declare which browser capabilities their UI needs. * Hosts MAY honor these by setting appropriate iframe `allow` attributes. * Apps SHOULD NOT assume permissions are granted; use JS feature detection as fallback. */ export const McpUiResourcePermissionsSchema = z.object({ - /** - * @description Request camera access. - * - * Maps to Permission Policy `camera` feature. - */ + /** @description Request camera access (Permission Policy `camera` feature). */ camera: z .object({}) .optional() - .describe( - "Request camera access.\n\nMaps to Permission Policy `camera` feature.", - ), - /** - * @description Request microphone access. - * - * Maps to Permission Policy `microphone` feature. - */ + .describe("Request camera access (Permission Policy `camera` feature)."), + /** @description Request microphone access (Permission Policy `microphone` feature). */ microphone: z .object({}) .optional() .describe( - "Request microphone access.\n\nMaps to Permission Policy `microphone` feature.", + "Request microphone access (Permission Policy `microphone` feature).", ), - /** - * @description Request geolocation access. - * - * Maps to Permission Policy `geolocation` feature. - */ + /** @description Request geolocation access (Permission Policy `geolocation` feature). */ geolocation: z .object({}) .optional() .describe( - "Request geolocation access.\n\nMaps to Permission Policy `geolocation` feature.", + "Request geolocation access (Permission Policy `geolocation` feature).", ), - /** - * @description Request clipboard write access. - * - * Maps to Permission Policy `clipboard-write` feature. - */ + /** @description Request clipboard write access (Permission Policy `clipboard-write` feature). */ clipboardWrite: z .object({}) .optional() .describe( - "Request clipboard write access.\n\nMaps to Permission Policy `clipboard-write` feature.", + "Request clipboard write access (Permission Policy `clipboard-write` feature).", ), }); /** - * @description Notification of UI size changes (View -> Host). - * @see {@link app!App.sendSizeChanged `App.sendSizeChanged`} for the method to send this from View + * @description Notification of UI size changes (Guest UI -> Host). + * @see {@link app!App.sendSizeChanged} for the method to send this from Guest UI */ export const McpUiSizeChangedNotificationSchema = z.object({ method: z.literal("ui/notifications/size-changed"), @@ -353,7 +260,7 @@ export const McpUiSizeChangedNotificationSchema = z.object({ }); /** - * @description Notification containing complete tool arguments (Host -> View). + * @description Notification containing complete tool arguments (Host -> Guest UI). */ export const McpUiToolInputNotificationSchema = z.object({ method: z.literal("ui/notifications/tool-input"), @@ -372,7 +279,7 @@ export const McpUiToolInputNotificationSchema = z.object({ }); /** - * @description Notification containing partial/streaming tool arguments (Host -> View). + * @description Notification containing partial/streaming tool arguments (Host -> Guest UI). */ export const McpUiToolInputPartialNotificationSchema = z.object({ method: z.literal("ui/notifications/tool-input-partial"), @@ -391,7 +298,7 @@ export const McpUiToolInputPartialNotificationSchema = z.object({ }); /** - * @description Notification that tool execution was cancelled (Host -> View). + * @description Notification that tool execution was cancelled (Host -> Guest UI). * Host MUST send this if tool execution was cancelled for any reason (user action, * sampling error, classifier intervention, etc.). */ @@ -412,7 +319,7 @@ export const McpUiToolCancelledNotificationSchema = z.object({ * @description CSS blocks that can be injected by apps. */ export const McpUiHostCssSchema = z.object({ - /** @description CSS for font loading (`@font-face` rules or `@import` statements). Apps must apply using {@link applyHostFonts `applyHostFonts`}. */ + /** @description CSS for font loading (`@font-face` rules or `@import` statements). Apps must apply using {@link applyHostFonts}. */ fonts: z.string().optional(), }); @@ -431,8 +338,8 @@ export const McpUiHostStylesSchema = z.object({ }); /** - * @description Request for graceful shutdown of the View (Host -> View). - * @see {@link app-bridge!AppBridge.teardownResource `AppBridge.teardownResource`} for the host method that sends this + * @description Request for graceful shutdown of the Guest UI (Host -> Guest UI). + * @see {@link app-bridge!AppBridge.teardownResource} for the host method that sends this */ export const McpUiResourceTeardownRequestSchema = z.object({ method: z.literal("ui/resource-teardown"), @@ -441,7 +348,7 @@ export const McpUiResourceTeardownRequestSchema = z.object({ /** * @description Result from graceful shutdown request. - * @see {@link McpUiResourceTeardownRequest `McpUiResourceTeardownRequest`} + * @see {@link McpUiResourceTeardownRequest} */ export const McpUiResourceTeardownResultSchema = z.record( z.string(), @@ -478,44 +385,21 @@ export const McpUiSupportedContentBlockModalitiesSchema = z.object({ .describe("Host supports structured content."), }); -/** - * @description Notification for app-initiated teardown request (View -> Host). - * Views send this to request that the host tear them down. The host decides - * whether to proceed - if approved, the host will send - * `ui/resource-teardown` to allow the view to perform cleanup before being - * unmounted. - * @see {@link app.App.requestTeardown} for the app method that sends this - */ -export const McpUiRequestTeardownNotificationSchema = z.object({ - method: z.literal("ui/notifications/request-teardown"), - params: z.object({}).optional(), -}); - /** * @description Capabilities supported by the host application. - * @see {@link McpUiInitializeResult `McpUiInitializeResult`} for the initialization result that includes these capabilities + * @see {@link McpUiInitializeResult} for the initialization result that includes these capabilities */ export const McpUiHostCapabilitiesSchema = z.object({ - /** @description Experimental features keyed by identifier. */ + /** @description Experimental features (structure TBD). */ experimental: z - .record( - z.string(), - z - .record(z.string(), z.any()) - .describe("Experimental features keyed by identifier."), - ) + .object({}) .optional() - .describe("Experimental features keyed by identifier."), + .describe("Experimental features (structure TBD)."), /** @description Host supports opening external URLs. */ openLinks: z .object({}) .optional() .describe("Host supports opening external URLs."), - /** @description Host supports file downloads via ui/download-file. */ - downloadFile: z - .object({}) - .optional() - .describe("Host supports file downloads via ui/download-file."), /** @description Host can proxy tool calls to the MCP server. */ serverTools: z .object({ @@ -559,45 +443,22 @@ export const McpUiHostCapabilitiesSchema = z.object({ McpUiSupportedContentBlockModalitiesSchema.optional().describe( "Host accepts context updates (ui/update-model-context) to be included in the model's context for future turns.", ), - /** @description Host supports receiving content messages (ui/message) from the view. */ + /** @description Host supports receiving content messages (ui/message) from the Guest UI. */ message: McpUiSupportedContentBlockModalitiesSchema.optional().describe( - "Host supports receiving content messages (ui/message) from the view.", + "Host supports receiving content messages (ui/message) from the Guest UI.", ), - /** - * @description Host supports LLM sampling (sampling/createMessage) from the view. - * Mirrors the MCP `ClientCapabilities.sampling` shape so hosts can pass it through. - */ - sampling: z - .object({ - /** @description Host supports tool use via `tools` and `toolChoice` parameters. */ - tools: z - .object({}) - .optional() - .describe( - "Host supports tool use via `tools` and `toolChoice` parameters.", - ), - }) - .optional() - .describe( - "Host supports LLM sampling (sampling/createMessage) from the view.\nMirrors the MCP `ClientCapabilities.sampling` shape so hosts can pass it through.", - ), }); /** - * @description Capabilities provided by the View ({@link app!App `App`}). - * @see {@link McpUiInitializeRequest `McpUiInitializeRequest`} for the initialization request that includes these capabilities + * @description Capabilities provided by the Guest UI ({@link app!App}). + * @see {@link McpUiInitializeRequest} for the initialization request that includes these capabilities */ export const McpUiAppCapabilitiesSchema = z.object({ - /** @description Experimental features keyed by identifier. */ + /** @description Experimental features (structure TBD). */ experimental: z - .record( - z.string(), - z - .record(z.string(), z.any()) - .describe("Experimental features keyed by identifier."), - ) + .object({}) .optional() - .describe("Experimental features keyed by identifier."), + .describe("Experimental features (structure TBD)."), /** @description App exposes MCP-style tools that the host can call. */ tools: z .object({ @@ -609,16 +470,11 @@ export const McpUiAppCapabilitiesSchema = z.object({ }) .optional() .describe("App exposes MCP-style tools that the host can call."), - /** @description Display modes the app supports. */ - availableDisplayModes: z - .array(McpUiDisplayModeSchema) - .optional() - .describe("Display modes the app supports."), }); /** - * @description Notification that View has completed initialization (View -> Host). - * @see {@link app!App.connect `App.connect`} for the method that sends this notification + * @description Notification that Guest UI has completed initialization (Guest UI -> Host). + * @see {@link app!App.connect} for the method that sends this notification */ export const McpUiInitializedNotificationSchema = z.object({ method: z.literal("ui/notifications/initialized"), @@ -629,55 +485,25 @@ export const McpUiInitializedNotificationSchema = z.object({ * @description UI Resource metadata for security and rendering configuration. */ export const McpUiResourceMetaSchema = z.object({ - /** @description Content Security Policy configuration for UI resources. */ + /** @description Content Security Policy configuration. */ csp: McpUiResourceCspSchema.optional().describe( - "Content Security Policy configuration for UI resources.", + "Content Security Policy configuration.", ), - /** @description Sandbox permissions requested by the UI resource. */ + /** @description Sandbox permissions requested by the UI. */ permissions: McpUiResourcePermissionsSchema.optional().describe( - "Sandbox permissions requested by the UI resource.", + "Sandbox permissions requested by the UI.", ), - /** - * @description Dedicated origin for view sandbox. - * - * Useful when views need stable, dedicated origins for OAuth callbacks, CORS policies, or API key allowlists. - * - * **Host-dependent:** The format and validation rules for this field are determined by each host. Servers MUST consult host-specific documentation for the expected domain format. Common patterns include: - * - Hash-based subdomains (e.g., `{hash}.claudemcpcontent.com`) - * - URL-derived subdomains (e.g., `www-example-com.oaiusercontent.com`) - * - * If omitted, host uses default sandbox origin (typically per-conversation). - * - * @example - * ```ts - * "a904794854a047f6.claudemcpcontent.com" - * ``` - * - * @example - * ```ts - * "www-example-com.oaiusercontent.com" - * ``` - */ + /** @description Dedicated origin for widget sandbox. */ domain: z .string() .optional() - .describe( - "Dedicated origin for view sandbox.\n\nUseful when views need stable, dedicated origins for OAuth callbacks, CORS policies, or API key allowlists.\n\n**Host-dependent:** The format and validation rules for this field are determined by each host. Servers MUST consult host-specific documentation for the expected domain format. Common patterns include:\n- Hash-based subdomains (e.g., `{hash}.claudemcpcontent.com`)\n- URL-derived subdomains (e.g., `www-example-com.oaiusercontent.com`)\n\nIf omitted, host uses default sandbox origin (typically per-conversation).", - ), - /** - * @description Visual boundary preference - true if view prefers a visible border. - * - * Boolean requesting whether a visible border and background is provided by the host. Specifying an explicit value for this is recommended because hosts' defaults may vary. - * - * - `true`: request visible border + background - * - `false`: request no visible border + background - * - omitted: host decides border - */ + .describe("Dedicated origin for widget sandbox."), + /** @description Visual boundary preference - true if UI prefers a visible border. */ prefersBorder: z .boolean() .optional() .describe( - "Visual boundary preference - true if view prefers a visible border.\n\nBoolean requesting whether a visible border and background is provided by the host. Specifying an explicit value for this is recommended because hosts' defaults may vary.\n\n- `true`: request visible border + background\n- `false`: request no visible border + background\n- omitted: host decides border", + "Visual boundary preference - true if UI prefers a visible border.", ), }); @@ -685,7 +511,7 @@ export const McpUiResourceMetaSchema = z.object({ * @description Request to change the display mode of the UI. * The host will respond with the actual display mode that was set, * which may differ from the requested mode if not supported. - * @see {@link app!App.requestDisplayMode `App.requestDisplayMode`} for the method that sends this request + * @see {@link app!App.requestDisplayMode} for the method that sends this request */ export const McpUiRequestDisplayModeRequestSchema = z.object({ method: z.literal("ui/request-display-mode"), @@ -697,7 +523,7 @@ export const McpUiRequestDisplayModeRequestSchema = z.object({ /** * @description Result from requesting a display mode change. - * @see {@link McpUiRequestDisplayModeRequest `McpUiRequestDisplayModeRequest`} + * @see {@link McpUiRequestDisplayModeRequest} */ export const McpUiRequestDisplayModeResultSchema = z .object({ @@ -721,11 +547,9 @@ export const McpUiToolVisibilitySchema = z export const McpUiToolMetaSchema = z.object({ /** * URI of the UI resource to display for this tool, if any. + * This is converted to `_meta["ui/resourceUri"]`. * - * @example - * ```ts - * "ui://weather/view.html" - * ``` + * @example "ui://weather/widget.html" */ resourceUri: z.string().optional(), /** @@ -739,64 +563,11 @@ export const McpUiToolMetaSchema = z.object({ .describe( 'Who can access this tool. Default: ["model", "app"]\n- "model": Tool visible to and callable by the agent\n- "app": Tool callable by the app from this server only', ), - /** - * `csp` belongs on the UI **resource** (see {@link McpUiResourceMeta}), - * not the tool. Hosts read it from the `resources/read` content item - * (with `resources/list` entry as fallback) and ignore it here. - */ - csp: z.never().optional(), - /** - * `permissions` belongs on the UI **resource** (see {@link McpUiResourceMeta}), - * not the tool. Hosts ignore it here. - */ - permissions: z.never().optional(), -}); - -/** - * @description MCP Apps capability settings advertised by clients to servers. - * - * Clients advertise these capabilities via the `extensions` field in their - * capabilities during MCP initialization. Servers can check for MCP Apps - * support using {@link server-helpers!getUiCapability}. - */ -export const McpUiClientCapabilitiesSchema = z.object({ - /** - * @description Array of supported MIME types for UI resources. - * Must include `"text/html;profile=mcp-app"` for MCP Apps support. - */ - mimeTypes: z - .array(z.string()) - .optional() - .describe( - 'Array of supported MIME types for UI resources.\nMust include `"text/html;profile=mcp-app"` for MCP Apps support.', - ), -}); - -/** - * @description Request to download a file through the host. - * - * Sent from the View to the Host when the app wants to trigger a file download. - * Since MCP Apps run in sandboxed iframes where direct downloads are blocked, - * this provides a host-mediated mechanism for file exports. - * The host SHOULD show a confirmation dialog before initiating the download. - * - * @see {@link app!App.downloadFile `App.downloadFile`} for the method that sends this request - */ -export const McpUiDownloadFileRequestSchema = z.object({ - method: z.literal("ui/download-file"), - params: z.object({ - /** @description Resource contents to download โ€” embedded (inline data) or linked (host fetches). Uses standard MCP resource types. */ - contents: z - .array(z.union([EmbeddedResourceSchema, ResourceLinkSchema])) - .describe( - "Resource contents to download \u2014 embedded (inline data) or linked (host fetches). Uses standard MCP resource types.", - ), - }), }); /** * @description Request to send a message to the host's chat interface. - * @see {@link app!App.sendMessage `App.sendMessage`} for the method that sends this request + * @see {@link app!App.sendMessage} for the method that sends this request */ export const McpUiMessageRequestSchema = z.object({ method: z.literal("ui/message"), @@ -815,7 +586,7 @@ export const McpUiMessageRequestSchema = z.object({ /** * @description Notification containing HTML resource for the sandbox proxy to load. * @internal - * @see https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#sandbox-proxy + * @see https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#sandbox-proxy */ export const McpUiSandboxResourceReadyNotificationSchema = z.object({ method: z.literal("ui/notifications/sandbox-resource-ready"), @@ -839,7 +610,7 @@ export const McpUiSandboxResourceReadyNotificationSchema = z.object({ }); /** - * @description Notification containing tool execution result (Host -> View). + * @description Notification containing tool execution result (Host -> Guest UI). */ export const McpUiToolResultNotificationSchema = z.object({ method: z.literal("ui/notifications/tool-result"), @@ -848,7 +619,7 @@ export const McpUiToolResultNotificationSchema = z.object({ }); /** - * @description Rich context about the host environment provided to views. + * @description Rich context about the host environment provided to Guest UIs. */ export const McpUiHostContextSchema = z .object({ @@ -880,7 +651,7 @@ export const McpUiHostContextSchema = z ), /** @description Display modes the host supports. */ availableDisplayModes: z - .array(McpUiDisplayModeSchema) + .array(z.string()) .optional() .describe("Display modes the host supports."), /** @@ -968,8 +739,8 @@ export const McpUiHostContextSchema = z .passthrough(); /** - * @description Notification that host context has changed (Host -> View). - * @see {@link McpUiHostContext `McpUiHostContext`} for the full context structure + * @description Notification that host context has changed (Host -> Guest UI). + * @see {@link McpUiHostContext} for the full context structure */ export const McpUiHostContextChangedNotificationSchema = z.object({ method: z.literal("ui/notifications/host-context-changed"), @@ -980,16 +751,16 @@ export const McpUiHostContextChangedNotificationSchema = z.object({ }); /** - * @description Request to update the agent's context without requiring a follow-up action (View -> Host). + * @description Request to update the agent's context without requiring a follow-up action (Guest UI -> Host). * * Unlike `notifications/message` which is for debugging/logging, this request is intended - * to update the Host's model context. Each request overwrites the previous context sent by the View. + * to update the Host's model context. Each request overwrites the previous context sent by the Guest UI. * Unlike messages, context updates do not trigger follow-ups. * * The host will typically defer sending the context to the model until the next user message * (including `ui/message`), and will only send the last update received. * - * @see {@link app.App.updateModelContext `App.updateModelContext`} for the method that sends this request + * @see {@link app.App.updateModelContext} for the method that sends this request */ export const McpUiUpdateModelContextRequestSchema = z.object({ method: z.literal("ui/update-model-context"), @@ -1013,8 +784,8 @@ export const McpUiUpdateModelContextRequestSchema = z.object({ }); /** - * @description Initialization request sent from View to Host. - * @see {@link app!App.connect `App.connect`} for the method that sends this request + * @description Initialization request sent from Guest UI to Host. + * @see {@link app!App.connect} for the method that sends this request */ export const McpUiInitializeRequestSchema = z.object({ method: z.literal("ui/initialize"), @@ -1033,8 +804,8 @@ export const McpUiInitializeRequestSchema = z.object({ }); /** - * @description Initialization result returned from Host to View. - * @see {@link McpUiInitializeRequest `McpUiInitializeRequest`} + * @description Initialization result returned from Host to Guest UI. + * @see {@link McpUiInitializeRequest} */ export const McpUiInitializeResultSchema = z .object({ diff --git a/src/interop-v1.test.ts b/src/interop-v1.test.ts deleted file mode 100644 index 31dbfa1..0000000 --- a/src/interop-v1.test.ts +++ /dev/null @@ -1,379 +0,0 @@ -/** - * Cross-version interop against the *published* 1.x package. - * - * `ext-apps-v1` is an npm alias for `@modelcontextprotocol/ext-apps@1.7.5` - * (built on `@modelcontextprotocol/sdk` 1.x). Each pairing wires a real - * `App` to a real `AppBridge` of the other major over an in-memory duplex, - * with the bridge fronting a real MCP `Client`+`McpServer` of its own SDK - * generation, and asserts the observable outcomes. Complements - * `wire-compat.test.ts`, which replays hand-shaped 1.x JSON instead. - */ -import { describe, it, expect, beforeAll, afterAll } from "bun:test"; -import { readFileSync } from "node:fs"; -import { - Client as ClientV2, - InMemoryTransport as InMemoryTransportV2, - ProtocolError, - type Transport, -} from "@modelcontextprotocol/client"; -import { EmptyResultSchema as EmptyResultSchemaV2 } from "@modelcontextprotocol/core"; -import { McpServer as McpServerV2 } from "@modelcontextprotocol/server"; -import { Client as ClientV1 } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport as InMemoryTransportV1 } from "@modelcontextprotocol/sdk/inMemory.js"; -import { McpServer as McpServerV1 } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { Protocol as ProtocolV1 } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import { - EmptyResultSchema as EmptyResultSchemaV1, - McpError, -} from "@modelcontextprotocol/sdk/types.js"; -import { App as AppV1 } from "ext-apps-v1"; -import { AppBridge as AppBridgeV1 } from "ext-apps-v1/app-bridge"; -import { z } from "zod/v4"; - -import { App as AppV2 } from "./app.js"; -import { AppBridge as AppBridgeV2 } from "./app-bridge.js"; - -const flush = () => new Promise((resolve) => setTimeout(resolve, 5)); - -/** In-memory duplex. Structurally a `Transport` for both SDK generations. */ -function createDuplex(): [Transport, Transport] { - const make = () => { - let closed = false; - const t: Transport & { peer?: typeof t } = { - async start() {}, - async send(message) { - // Serialize like a real transport so each side parses plain JSON. - t.peer?.onmessage?.(JSON.parse(JSON.stringify(message))); - }, - async close() { - if (closed) return; - closed = true; - t.onclose?.(); - await t.peer?.close(); - }, - }; - return t; - }; - const [a, b] = [make(), make()]; - a.peer = b; - b.peer = a; - return [a, b]; -} - -/** One SDK generation's MCP pieces, as used by the App side or the host side. */ -const sdkV1 = { - Client: ClientV1, - McpServer: McpServerV1, - InMemoryTransport: InMemoryTransportV1, - EmptyResultSchema: EmptyResultSchemaV1, - error: (code: number, message: string): Error => new McpError(code, message), -}; -const sdkV2 = { - Client: ClientV2, - McpServer: McpServerV2, - InMemoryTransport: InMemoryTransportV2, - EmptyResultSchema: EmptyResultSchemaV2, - error: (code: number, message: string): Error => - new ProtocolError(code, message), -}; - -const pairings = [ - { - name: "2.x App with 1.x AppBridge (sdk 1.x host)", - App: AppV2 as any, - AppBridge: AppBridgeV1 as any, - view: sdkV2, - host: sdkV1, - // sdk 1.x reports a Zod params failure as InternalError. - invalidParamsCode: -32603, - // sdk 1.x forwards a handler-thrown -32002 unchanged. - handlerThrown32002Code: -32002, - // McpServer 1.x answers an unknown tool with an isError result. - unknownToolRejects: false, - }, - { - name: "1.x App with 2.x AppBridge (sdk 2.x host)", - App: AppV1 as any, - AppBridge: AppBridgeV2 as any, - view: sdkV1, - host: sdkV2, - invalidParamsCode: -32602, - // SDK 2.x never emits -32002 on the wire (see wire-compat.test.ts). - handlerThrown32002Code: -32602, - // McpServer 2.x answers an unknown tool with a JSON-RPC error. - unknownToolRejects: true, - }, -]; - -const hostInfo = { name: "TestHost", version: "9.9.9" }; -const appInfo = { name: "TestView", version: "1.2.3" }; -const hostCapabilities = { - openLinks: {}, - serverTools: {}, - serverResources: {}, - logging: {}, - updateModelContext: {}, -}; -const hostContext = { theme: "dark", displayMode: "inline", locale: "en-US" }; - -it("pits 2.x against the published 1.x package, not against itself", () => { - const v1PackageJson = JSON.parse( - readFileSync( - new URL("../../package.json", import.meta.resolve("ext-apps-v1")), - "utf8", - ), - ); - expect(v1PackageJson).toMatchObject({ - name: "@modelcontextprotocol/ext-apps", - version: "1.7.5", - }); - // 1.x classes extend the sdk-1 Protocol; 2.x classes do not. - expect(AppV1.prototype).toBeInstanceOf(ProtocolV1); - expect(AppBridgeV1.prototype).toBeInstanceOf(ProtocolV1); - expect(AppV2.prototype).not.toBeInstanceOf(ProtocolV1); - expect(AppBridgeV2.prototype).not.toBeInstanceOf(ProtocolV1); -}); - -// The tests in each pairing share one connected App/AppBridge fixture and run -// in file order (bun does not randomize); later tests assume earlier ones -// left the pair connected. -describe.each(pairings)("$name", (pairing) => { - const { view, host } = pairing; - let server: any, client: any, bridge: any, app: any; - const events: string[] = []; - const record = (name: string) => (params?: unknown) => { - events.push(`${name}:${JSON.stringify(params ?? null)}`); - }; - const drain = () => events.splice(0); - // Captured inside oninitialized: the bridge must have processed - // ui/initialize before ui/notifications/initialized arrives. - let appVersionAtInitialized: unknown = "not-fired"; - let hostAbortReason: unknown; - - beforeAll(async () => { - server = new host.McpServer({ name: "TestServer", version: "1.0.0" }); - server.registerTool( - "echo", - { inputSchema: z.object({ a: z.number() }) }, - async ({ a }: { a: number }) => ({ - content: [{ type: "text", text: `echo ${a}` }], - }), - ); - server.registerTool("boom", { inputSchema: z.object({}) }, async () => { - throw new Error("tool exploded"); - }); - server.registerResource( - "res", - "test://res", - { mimeType: "text/plain" }, - async (uri: URL) => ({ - contents: [{ uri: uri.href, mimeType: "text/plain", text: "hello" }], - }), - ); - const [clientTransport, serverTransport] = - host.InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - client = new host.Client({ name: "HostOuterClient", version: "1.0.0" }); - await client.connect(clientTransport); - - bridge = new pairing.AppBridge(client, hostInfo, hostCapabilities, { - hostContext, - }); - app = new pairing.App( - appInfo, - { tools: { listChanged: true } }, - { - autoResize: false, - }, - ); - bridge.oninitialized = () => { - appVersionAtInitialized = bridge.getAppVersion(); - }; - for (const name of ["onsizechange", "onloggingmessage"]) { - bridge[name] = record(`bridge.${name}`); - } - for (const name of ["onmessage", "onupdatemodelcontext"]) { - bridge[name] = async (params: unknown) => { - record(`bridge.${name}`)(params); - return {}; - }; - } - bridge.onopenlink = (params: { url: string }, extra: any) => { - record("bridge.onopenlink")(params); - if (params.url !== "https://slow") return {}; - // Hang until the view cancels; sdk 1.x passes `extra.signal`, 2.x `extra.mcpReq.signal`. - const signal: AbortSignal = extra.signal ?? extra.mcpReq.signal; - return new Promise((_resolve, reject) => { - signal.addEventListener("abort", () => { - hostAbortReason = signal.reason; - reject(new Error("aborted")); - }); - }); - }; - for (const name of [ - "ontoolinput", - "ontoolresult", - "ontoolcancelled", - "onhostcontextchanged", - ]) { - app[name] = record(`app.${name}`); - } - app.onteardown = async (params: unknown) => { - record("app.onteardown")(params); - return {}; - }; - }); - - afterAll(async () => { - await app?.close().catch(() => {}); - await bridge?.close().catch(() => {}); - await client?.close().catch(() => {}); - await server?.close().catch(() => {}); - }); - - it("completes ui/initialize and delivers initialized in order", async () => { - const [appTransport, bridgeTransport] = createDuplex(); - // Tap the raw ui/initialize result so an additive key on the 2.x side - // (which the getters below would not surface) still fails here. - let initializeResult: unknown; - const send = bridgeTransport.send.bind(bridgeTransport); - bridgeTransport.send = async (message, options) => { - if ("result" in message && "id" in message && message.id === 0) { - initializeResult = message.result; - } - await send(message, options); - }; - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - await flush(); - - expect(app.getHostContext()).toEqual(hostContext); - expect(app.getHostCapabilities()).toEqual(hostCapabilities); - expect(app.getHostVersion()).toEqual(hostInfo); - expect(bridge.getAppCapabilities()).toEqual({ - tools: { listChanged: true }, - }); - expect(appVersionAtInitialized).toEqual(appInfo); - expect(initializeResult).toEqual({ - protocolVersion: "2026-01-26", - hostCapabilities, - hostInfo, - hostContext, - }); - }); - - it("delivers host-to-view notifications", async () => { - bridge.sendToolInput({ arguments: { location: "NYC" } }); - bridge.sendToolResult({ content: [{ type: "text", text: "sunny" }] }); - bridge.sendToolCancelled({ reason: "user" }); - bridge.setHostContext({ theme: "light" }); - await flush(); - - expect(drain()).toEqual([ - 'app.ontoolinput:{"arguments":{"location":"NYC"}}', - 'app.ontoolresult:{"content":[{"type":"text","text":"sunny"}]}', - 'app.ontoolcancelled:{"reason":"user"}', - 'app.onhostcontextchanged:{"theme":"light"}', - ]); - expect(app.getHostContext()).toEqual({ ...hostContext, theme: "light" }); - }); - - it("round-trips view-to-host requests and notifications", async () => { - const message = { role: "user", content: [{ type: "text", text: "hi" }] }; - expect(await app.sendMessage(message)).toEqual({}); - expect(await app.openLink({ url: "https://example.com" })).toEqual({}); - expect(await app.requestDisplayMode({ mode: "fullscreen" })).toEqual({ - mode: "inline", - }); - bridge.onrequestdisplaymode = async (params: { mode: string }) => params; - expect(await app.requestDisplayMode({ mode: "fullscreen" })).toEqual({ - mode: "fullscreen", - }); - const context = { content: [{ type: "text", text: "ctx" }] }; - expect(await app.updateModelContext(context)).toEqual({}); - await app.sendLog({ level: "info", data: { m: "hello" } }); - await app.sendSizeChanged({ width: 320, height: 240 }); - await flush(); - - expect(drain()).toEqual([ - `bridge.onmessage:${JSON.stringify(message)}`, - 'bridge.onopenlink:{"url":"https://example.com"}', - `bridge.onupdatemodelcontext:${JSON.stringify(context)}`, - 'bridge.onloggingmessage:{"level":"info","data":{"m":"hello"}}', - 'bridge.onsizechange:{"width":320,"height":240}', - ]); - }); - - it("proxies tools/call and resources/read to the real MCP server", async () => { - expect( - await app.callServerTool({ name: "echo", arguments: { a: 1 } }), - ).toEqual({ content: [{ type: "text", text: "echo 1" }] }); - expect( - await app.callServerTool({ name: "boom", arguments: {} }), - ).toMatchObject({ - isError: true, - content: [{ type: "text", text: "tool exploded" }], - }); - const unknown = app.callServerTool({ name: "nope", arguments: {} }); - if (pairing.unknownToolRejects) { - await expect(unknown).rejects.toMatchObject({ code: -32602 }); - } else { - expect(await unknown).toMatchObject({ isError: true }); - } - expect(await app.readServerResource({ uri: "test://res" })).toEqual({ - contents: [{ uri: "test://res", mimeType: "text/plain", text: "hello" }], - }); - }); - - it("propagates JSON-RPC errors with the expected codes", async () => { - const raw = (method: string, params: unknown) => - app.request({ method, params }, view.EmptyResultSchema); - await expect(raw("ui/does-not-exist", {})).rejects.toMatchObject({ - code: -32601, - }); - await expect(raw("ui/open-link", { url: 42 })).rejects.toMatchObject({ - code: pairing.invalidParamsCode, - }); - bridge.onreadresource = async () => { - throw host.error(-32002, "Resource not found (host-thrown -32002)"); - }; - const notFound = app.readServerResource({ uri: "test://missing" }); - await expect(notFound).rejects.toMatchObject({ - code: pairing.handlerThrown32002Code, - message: expect.stringContaining( - "Resource not found (host-thrown -32002)", - ), - }); - }); - - it("cancels an in-flight request via AbortSignal on both ends", async () => { - const controller = new AbortController(); - const pending = app.openLink( - { url: "https://slow" }, - { signal: controller.signal }, - ); - await flush(); - controller.abort("user-cancelled"); - await expect(pending).rejects.toMatchObject({ - message: expect.stringContaining("user-cancelled"), - }); - await flush(); - expect(String(hostAbortReason)).toContain("user-cancelled"); - drain(); - }); - - it("tears down, then closes both ends", async () => { - expect(await bridge.teardownResource({})).toEqual({}); - expect(drain()).toEqual(["app.onteardown:{}"]); - - const closed: string[] = []; - app.onclose = () => closed.push("app"); - bridge.onclose = () => closed.push("bridge"); - await app.close(); - await flush(); - expect(closed.sort()).toEqual(["app", "bridge"]); - await expect(app.openLink({ url: "https://after-close" })).rejects.toThrow( - "Not connected", - ); - }); -}); diff --git a/src/legacy-handlers.test.ts b/src/legacy-handlers.test.ts deleted file mode 100644 index 0ef36d6..0000000 --- a/src/legacy-handlers.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; -import { InMemoryTransport, Server } from "@modelcontextprotocol/server"; -import { z } from "zod/v4"; - -import { App } from "./app.js"; -import { AppBridge } from "./app-bridge.js"; -import { - McpUiInitializeRequestSchema, - McpUiInitializeResultSchema, - McpUiInitializedNotificationSchema, - McpUiResourceTeardownRequestSchema, - McpUiToolInputNotificationSchema, - McpUiSizeChangedNotificationSchema, - McpUiOpenLinkRequestSchema, -} from "./types.js"; - -const CustomRequestSchema = z.object({ - method: z.literal("test/echo"), - params: z.object({ text: z.string() }), -}); - -const CustomNotificationSchema = z.object({ - method: z.literal("test/ping"), - params: z.object({ n: z.number() }), -}); - -let warn: ReturnType<typeof spyOn>; -beforeEach(() => { - warn = spyOn(console, "warn").mockImplementation(() => {}); -}); -afterEach(() => warn.mockRestore()); - -describe("1.x setRequestHandler(Schema, handler) on App", () => { - it("registers a custom request and hands the handler the whole message plus a 1.x extra", async () => { - const app = new App({ name: "a", version: "1" }, {}, { autoResize: false }); - const server = new Server( - { name: "s", version: "1" }, - { capabilities: {} }, - ); - server.setRequestHandler( - "ui/initialize", - { - params: McpUiInitializeRequestSchema.shape.params, - result: McpUiInitializeResultSchema, - }, - () => ({ - protocolVersion: "2026-01-26", - hostCapabilities: {}, - hostInfo: { name: "h", version: "1" }, - hostContext: {}, - }), - ); - server.setNotificationHandler( - "ui/notifications/initialized", - { params: McpUiInitializedNotificationSchema.shape.params }, - () => {}, - ); - - let seen: unknown; - let seenExtra: { signal: AbortSignal; requestId: unknown } | undefined; - app.setRequestHandler(CustomRequestSchema, (request, extra) => { - seen = request; - seenExtra = extra; - return { echoed: request.params.text }; - }); - - const [at, st] = InMemoryTransport.createLinkedPair(); - await server.connect(st); - await app.connect(at); - - const result = await server.request( - { method: "test/echo", params: { text: "hi" } }, - z.object({ echoed: z.string() }), - ); - - expect(result).toEqual({ echoed: "hi" }); - expect(seen).toEqual({ method: "test/echo", params: { text: "hi" } }); - expect(seenExtra?.signal).toBeInstanceOf(AbortSignal); - expect(seenExtra?.requestId).toBeDefined(); - expect(warn).toHaveBeenCalledTimes(1); - expect(String(warn.mock.calls[0][0])).toContain( - 'setRequestHandler("test/echo", { params: Schema.shape.params }, handler)', - ); - - await app.close(); - await server.close(); - }); - - it("still throws for a method an on* setter already owns", () => { - const app = new App({ name: "a", version: "1" }, {}, { autoResize: false }); - app.onteardown = async () => ({}); - expect(() => - app.setRequestHandler( - McpUiResourceTeardownRequestSchema, - async () => ({}), - ), - ).toThrow(/already registered/); - }); - - it("still throws for an event-mapped notification", () => { - const app = new App({ name: "a", version: "1" }, {}, { autoResize: false }); - app.ontoolinput = () => {}; - expect(() => - app.setNotificationHandler(McpUiToolInputNotificationSchema, () => {}), - ).toThrow(/already registered/); - }); -}); - -describe("1.x setNotificationHandler(Schema, handler) on AppBridge", () => { - it("registers a custom notification and hands the handler the whole message", async () => { - const bridge = new AppBridge(null, { name: "h", version: "1" }, {}); - // A bare Server stands in for the View: unlike Client, connect() sends no - // MCP initialize, matching what a real App does on this channel. - const view = new Server({ name: "v", version: "1" }, { capabilities: {} }); - let seen: unknown; - bridge.setNotificationHandler(CustomNotificationSchema, (n) => { - seen = n; - }); - - const [bt, vt] = InMemoryTransport.createLinkedPair(); - await bridge.connect(bt); - await view.connect(vt); - await view.notification({ method: "test/ping", params: { n: 3 } }); - await new Promise((r) => setTimeout(r, 10)); - - expect(seen).toEqual({ method: "test/ping", params: { n: 3 } }); - await view.close(); - await bridge.close(); - }); - - it("still throws for a method an on* setter already owns", () => { - const bridge = new AppBridge(null, { name: "h", version: "1" }, {}); - bridge.onsizechange = () => {}; - expect(() => - bridge.setNotificationHandler( - McpUiSizeChangedNotificationSchema, - () => {}, - ), - ).toThrow(/already registered/); - bridge.onopenlink = async () => ({}); - expect(() => - bridge.setRequestHandler(McpUiOpenLinkRequestSchema, async () => ({})), - ).toThrow(/already registered/); - }); -}); diff --git a/src/legacy-handlers.ts b/src/legacy-handlers.ts deleted file mode 100644 index 237b8eb..0000000 --- a/src/legacy-handlers.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { BaseContext, RequestId } from "@modelcontextprotocol/client"; -import type { ZodLiteral, ZodObject, ZodType } from "zod/v4"; - -/** - * A whole-message Zod schema (`method` literal plus `params`), as accepted by - * the 1.x `setRequestHandler(Schema, handler)` forms. - */ -export type LegacyMethodSchema = ZodObject<{ - method: ZodLiteral<string>; - params: ZodType; -}>; - -/** - * The `extra` argument 1.x request handlers received. - * - * @deprecated Use the 2.x `BaseContext` (`extra.mcpReq.signal`, `extra.mcpReq.id`). - */ -export type LegacyRequestHandlerExtra = { - signal: AbortSignal; - requestId: RequestId; - sessionId?: string; - _meta?: BaseContext["mcpReq"]["_meta"]; - sendRequest: BaseContext["mcpReq"]["send"]; - sendNotification: BaseContext["mcpReq"]["notify"]; - authInfo?: NonNullable<BaseContext["http"]>["authInfo"]; -}; - -/** @deprecated Use `setRequestHandler("method", { params, result }, (params, ctx) => โ€ฆ)`. */ -export type LegacyRequestHandler<S extends LegacyMethodSchema> = ( - request: S["_output"], - extra: LegacyRequestHandlerExtra, -) => unknown; - -/** @deprecated Use `setNotificationHandler("method", { params }, (params) => โ€ฆ)`. */ -export type LegacyNotificationHandler<S extends LegacyMethodSchema> = ( - notification: S["_output"], -) => void | Promise<void>; - -/** - * `setRequestHandler` with the 1.x `(Schema, handler)` form kept as a - * deprecated overload next to the 2.x forms in `Modern`. - */ -export type LegacyRequestHandlerSetter<Modern> = Modern & { - /** - * @deprecated 1.x form. Pass the method name and `{ params }` instead; - * removed in 3.0. - */ - <S extends LegacyMethodSchema>( - schema: S, - handler: LegacyRequestHandler<S>, - ): void; -}; - -/** `setNotificationHandler` counterpart of {@link LegacyRequestHandlerSetter}. */ -export type LegacyNotificationHandlerSetter<Modern> = Modern & { - /** - * @deprecated 1.x form. Pass the method name and `{ params }` instead; - * removed in 3.0. - */ - <S extends LegacyMethodSchema>( - schema: S, - handler: LegacyNotificationHandler<S>, - ): void; -}; - -/** - * Arguments for the 2.x three-argument `setRequestHandler` / - * `setNotificationHandler` form. - */ -type ModernArgs = [ - method: string, - schemas: { params: ZodType }, - handler: (params: unknown, ctx: BaseContext) => unknown, -]; - -const warned = new Set<string>(); - -/** - * Translate a 1.x `(Schema, handler)` registration into the 2.x - * `(method, { params }, handler)` form, or return `undefined` when the call - * is already in the 2.x form. The 1.x handler receives the reassembled - * `{ method, params }` message and, for requests, a 1.x-shaped `extra`. - */ -export function toModernArgs( - kind: "request" | "notification", - args: unknown[], -): ModernArgs | undefined { - const [first, handler] = args; - const shape = (first as Partial<LegacyMethodSchema> | undefined)?.shape; - if (typeof shape?.method?.value !== "string") return undefined; - const method = shape.method.value; - const setter = - kind === "request" ? "setRequestHandler" : "setNotificationHandler"; - if (!warned.has(setter)) { - warned.add(setter); - console.warn( - `[ext-apps] ${setter}(Schema, handler) is deprecated; use ` + - `${setter}("${method}", { params: Schema.shape.params }, handler).`, - ); - } - const call = handler as (...a: unknown[]) => unknown; - return [ - method, - { params: shape.params }, - kind === "request" - ? (params, ctx) => call({ method, params }, legacyExtra(ctx)) - : (params) => call({ method, params }), - ]; -} - -function legacyExtra(ctx: BaseContext): LegacyRequestHandlerExtra { - return { - signal: ctx.mcpReq.signal, - requestId: ctx.mcpReq.id, - sessionId: ctx.sessionId, - _meta: ctx.mcpReq._meta, - sendRequest: ctx.mcpReq.send, - sendNotification: ctx.mcpReq.notify, - authInfo: ctx.http?.authInfo, - }; -} diff --git a/src/message-transport.examples.ts b/src/message-transport.examples.ts deleted file mode 100644 index b42184b..0000000 --- a/src/message-transport.examples.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Type-checked examples for {@link PostMessageTransport `PostMessageTransport`}. - * - * These examples are included in the API documentation via `@includeCode` tags. - * Each function's region markers define the code snippet that appears in the docs. - * - * @module - */ - -import { PostMessageTransport } from "./message-transport.js"; -import type { App } from "./app.js"; -import type { AppBridge } from "./app-bridge.js"; - -/** - * Example: View connecting to parent window. - */ -async function PostMessageTransport_view(app: App) { - //#region PostMessageTransport_view - const transport = new PostMessageTransport(window.parent, window.parent); - await app.connect(transport); - //#endregion PostMessageTransport_view -} - -/** - * Example: Host connecting to an iframe. - */ -async function PostMessageTransport_host(bridge: AppBridge) { - //#region PostMessageTransport_host - const iframe = document.getElementById("app-iframe") as HTMLIFrameElement; - const transport = new PostMessageTransport( - iframe.contentWindow!, - iframe.contentWindow!, - ); - await bridge.connect(transport); - //#endregion PostMessageTransport_host -} - -/** - * Example: Creating transport for view (constructor only). - */ -function PostMessageTransport_constructor_view() { - //#region PostMessageTransport_constructor_view - const transport = new PostMessageTransport(window.parent, window.parent); - //#endregion PostMessageTransport_constructor_view -} - -/** - * Example: Creating transport for host (constructor only). - */ -function PostMessageTransport_constructor_host() { - //#region PostMessageTransport_constructor_host - const iframe = document.getElementById("app-iframe") as HTMLIFrameElement; - const transport = new PostMessageTransport( - iframe.contentWindow!, - iframe.contentWindow!, - ); - //#endregion PostMessageTransport_constructor_host -} diff --git a/src/message-transport.test.ts b/src/message-transport.test.ts deleted file mode 100644 index e2518cc..0000000 --- a/src/message-transport.test.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"; -import type { JSONRPCMessage } from "@modelcontextprotocol/client"; - -import { PostMessageTransport } from "./message-transport.js"; - -/** - * Minimal `window` stub for bun's DOM-less test environment. - * - * Captures listeners registered via `addEventListener` so tests can dispatch - * fake `MessageEvent`-like objects directly. We deliberately avoid pulling in - * jsdom/happy-dom โ€” this file tests the transport's contract, not the browser. - */ -type Listener = (event: MessageEvent) => void; - -function createFakeWindow() { - const listeners = new Map<string, Set<Listener>>(); - return { - addEventListener(type: string, listener: Listener) { - if (!listeners.has(type)) listeners.set(type, new Set()); - listeners.get(type)!.add(listener); - }, - removeEventListener(type: string, listener: Listener) { - listeners.get(type)?.delete(listener); - }, - /** Test helper: invoke all listeners for `type` with the given event. */ - dispatch(type: string, event: unknown) { - listeners.get(type)?.forEach((l) => l(event as MessageEvent)); - }, - /** Test helper: how many listeners are registered for `type`? */ - listenerCount(type: string) { - return listeners.get(type)?.size ?? 0; - }, - }; -} - -/** - * A valid JSON-RPC request payload. Used by most happy-path tests. - * Kept deliberately minimal โ€” we care that *a* valid message is delivered, - * not about exercising every JSON-RPC shape (that's the schema's job). - */ -const validRequest: JSONRPCMessage = { - jsonrpc: "2.0", - id: 1, - method: "ping", -}; - -describe("PostMessageTransport", () => { - let fakeWindow: ReturnType<typeof createFakeWindow>; - let trustedSource: object; - let untrustedSource: object; - - /** Stand-in for the target window (e.g. window.parent or iframe.contentWindow). */ - let targetPostMessage: ReturnType<typeof mock>; - let eventTarget: { postMessage: typeof targetPostMessage }; - - // The transport logs liberally at debug/error level. Silence during tests - // so failures are readable; restore afterwards so we don't leak state. - let restoreConsole: () => void; - - beforeEach(() => { - fakeWindow = createFakeWindow(); - (globalThis as { window?: unknown }).window = fakeWindow; - - // Distinct object identities โ€” the transport compares with `!==`. - trustedSource = { id: "trusted" }; - untrustedSource = { id: "untrusted" }; - - targetPostMessage = mock(() => {}); - eventTarget = { postMessage: targetPostMessage }; - - const origDebug = console.debug; - const origError = console.error; - console.debug = () => {}; - console.error = () => {}; - restoreConsole = () => { - console.debug = origDebug; - console.error = origError; - }; - }); - - afterEach(() => { - restoreConsole(); - delete (globalThis as { window?: unknown }).window; - }); - - /** Create and start a transport wired to the shared fakes. */ - async function createStartedTransport() { - const transport = new PostMessageTransport( - eventTarget as unknown as Window, - trustedSource as MessageEventSource, - ); - await transport.start(); - return transport; - } - - // ========================================================================== - // Source validation โ€” the security boundary at this layer. - // The transport validates `event.source` (window identity), not `event.origin`. - // Origin checks live in the sandbox proxy relay between the two endpoints - // (see examples/basic-host/src/sandbox.ts). Here, source===contentWindow is - // the narrower check; the app side can't know its sandbox's origin anyway. - // ========================================================================== - describe("source validation", () => { - it("delivers messages from the configured eventSource", async () => { - const transport = await createStartedTransport(); - const received: JSONRPCMessage[] = []; - transport.onmessage = (msg) => received.push(msg); - - fakeWindow.dispatch("message", { - source: trustedSource, - data: validRequest, - }); - - expect(received).toEqual([validRequest]); - }); - - it("drops messages from a different source", async () => { - const transport = await createStartedTransport(); - const received: JSONRPCMessage[] = []; - const errors: Error[] = []; - transport.onmessage = (msg) => received.push(msg); - transport.onerror = (err) => errors.push(err); - - fakeWindow.dispatch("message", { - source: untrustedSource, - data: validRequest, - }); - - // The message is silently dropped โ€” neither delivered nor surfaced as - // an error. An attacker flooding us with forged messages should not be - // able to DoS the error handler. - expect(received).toEqual([]); - expect(errors).toEqual([]); - }); - - it("drops messages with a null source", async () => { - // Some browser-injected messages (extensions, devtools) arrive with - // source === null. These must not reach the protocol layer. - const transport = await createStartedTransport(); - const received: JSONRPCMessage[] = []; - transport.onmessage = (msg) => received.push(msg); - - fakeWindow.dispatch("message", { - source: null, - data: validRequest, - }); - - expect(received).toEqual([]); - }); - - it("continues delivering trusted messages after dropping an untrusted one", async () => { - // Regression guard: a rejected message must not break the listener or - // close the transport. - const transport = await createStartedTransport(); - const received: JSONRPCMessage[] = []; - transport.onmessage = (msg) => received.push(msg); - - fakeWindow.dispatch("message", { - source: untrustedSource, - data: validRequest, - }); - fakeWindow.dispatch("message", { - source: trustedSource, - data: validRequest, - }); - - expect(received).toEqual([validRequest]); - }); - }); - - // ========================================================================== - // Message format validation. - // Three paths: valid โ†’ onmessage, non-JSON-RPC โ†’ silent, malformed โ†’ onerror. - // ========================================================================== - describe("message format validation", () => { - it("delivers valid JSON-RPC requests via onmessage", async () => { - const transport = await createStartedTransport(); - const received: JSONRPCMessage[] = []; - transport.onmessage = (msg) => received.push(msg); - - fakeWindow.dispatch("message", { - source: trustedSource, - data: { jsonrpc: "2.0", id: 42, method: "tools/call" }, - }); - - expect(received).toHaveLength(1); - expect(received[0]).toMatchObject({ id: 42, method: "tools/call" }); - }); - - it("delivers valid JSON-RPC notifications via onmessage", async () => { - const transport = await createStartedTransport(); - const received: JSONRPCMessage[] = []; - transport.onmessage = (msg) => received.push(msg); - - fakeWindow.dispatch("message", { - source: trustedSource, - data: { jsonrpc: "2.0", method: "notifications/initialized" }, - }); - - expect(received).toHaveLength(1); - expect(received[0]).toMatchObject({ - method: "notifications/initialized", - }); - }); - - it("silently ignores non-JSON-RPC payloads", async () => { - // Real iframes receive all sorts of ambient postMessage traffic: - // React DevTools, browser extensions, ad frames. These must not crash - // the transport or surface as protocol errors. - const transport = await createStartedTransport(); - const received: JSONRPCMessage[] = []; - const errors: Error[] = []; - transport.onmessage = (msg) => received.push(msg); - transport.onerror = (err) => errors.push(err); - - fakeWindow.dispatch("message", { - source: trustedSource, - data: { type: "react-devtools-hook", payload: {} }, - }); - fakeWindow.dispatch("message", { source: trustedSource, data: "hello" }); - fakeWindow.dispatch("message", { source: trustedSource, data: null }); - fakeWindow.dispatch("message", { source: trustedSource, data: 42 }); - - expect(received).toEqual([]); - expect(errors).toEqual([]); - }); - - it("calls onerror for malformed messages claiming to be JSON-RPC", async () => { - // `jsonrpc: "2.0"` but missing required fields โ€” this IS a protocol - // violation worth surfacing, unlike ambient noise. - const transport = await createStartedTransport(); - const received: JSONRPCMessage[] = []; - const errors: Error[] = []; - transport.onmessage = (msg) => received.push(msg); - transport.onerror = (err) => errors.push(err); - - fakeWindow.dispatch("message", { - source: trustedSource, - data: { jsonrpc: "2.0" }, // no id/method/result โ€” invalid - }); - - expect(received).toEqual([]); - expect(errors).toHaveLength(1); - expect(errors[0]).toBeInstanceOf(Error); - }); - - it("does not throw when onerror is unset and a malformed message arrives", async () => { - const transport = await createStartedTransport(); - transport.onmessage = () => {}; - // onerror deliberately left unset - - expect(() => - fakeWindow.dispatch("message", { - source: trustedSource, - data: { jsonrpc: "2.0" }, - }), - ).not.toThrow(); - }); - - it("does not throw when onmessage is unset and a valid message arrives", async () => { - await createStartedTransport(); - // onmessage deliberately left unset โ€” may happen briefly during wiring - - expect(() => - fakeWindow.dispatch("message", { - source: trustedSource, - data: validRequest, - }), - ).not.toThrow(); - }); - }); - - // ========================================================================== - // send() - // ========================================================================== - describe("send()", () => { - it("posts the message to the configured eventTarget with '*' origin", async () => { - const transport = await createStartedTransport(); - - const msg: JSONRPCMessage = { - jsonrpc: "2.0", - id: 7, - method: "ui/initialize", - }; - await transport.send(msg); - - expect(targetPostMessage).toHaveBeenCalledTimes(1); - expect(targetPostMessage).toHaveBeenCalledWith(msg, "*"); - }); - - it("posts multiple messages in order", async () => { - const transport = await createStartedTransport(); - - const a: JSONRPCMessage = { jsonrpc: "2.0", id: 1, method: "a" }; - const b: JSONRPCMessage = { jsonrpc: "2.0", id: 2, method: "b" }; - await transport.send(a); - await transport.send(b); - - expect(targetPostMessage).toHaveBeenCalledTimes(2); - expect(targetPostMessage.mock.calls[0][0]).toEqual(a); - expect(targetPostMessage.mock.calls[1][0]).toEqual(b); - }); - }); - - // ========================================================================== - // Lifecycle: start() / close() - // ========================================================================== - describe("lifecycle", () => { - it("start() registers a message listener on window", async () => { - const transport = new PostMessageTransport( - eventTarget as unknown as Window, - trustedSource as MessageEventSource, - ); - - expect(fakeWindow.listenerCount("message")).toBe(0); - await transport.start(); - expect(fakeWindow.listenerCount("message")).toBe(1); - }); - - it("close() removes the message listener", async () => { - const transport = await createStartedTransport(); - - expect(fakeWindow.listenerCount("message")).toBe(1); - await transport.close(); - expect(fakeWindow.listenerCount("message")).toBe(0); - }); - - it("messages dispatched after close() are not delivered", async () => { - const transport = await createStartedTransport(); - const received: JSONRPCMessage[] = []; - transport.onmessage = (msg) => received.push(msg); - - await transport.close(); - fakeWindow.dispatch("message", { - source: trustedSource, - data: validRequest, - }); - - expect(received).toEqual([]); - }); - - it("close() invokes onclose when set", async () => { - const transport = await createStartedTransport(); - const onclose = mock(() => {}); - transport.onclose = onclose; - - await transport.close(); - - expect(onclose).toHaveBeenCalledTimes(1); - }); - - it("close() does not throw when onclose is unset", async () => { - const transport = await createStartedTransport(); - // onclose deliberately left unset - - expect(transport.close()).resolves.toBeUndefined(); - }); - - it("two transports listen independently", async () => { - // Host scenario: multiple iframes, each with its own transport. - // Each transport must only accept messages from its own iframe. - const sourceA = { id: "iframe-a" }; - const sourceB = { id: "iframe-b" }; - - const transportA = new PostMessageTransport( - eventTarget as unknown as Window, - sourceA as unknown as MessageEventSource, - ); - const transportB = new PostMessageTransport( - eventTarget as unknown as Window, - sourceB as unknown as MessageEventSource, - ); - await transportA.start(); - await transportB.start(); - - const receivedA: JSONRPCMessage[] = []; - const receivedB: JSONRPCMessage[] = []; - transportA.onmessage = (msg) => receivedA.push(msg); - transportB.onmessage = (msg) => receivedB.push(msg); - - fakeWindow.dispatch("message", { source: sourceA, data: validRequest }); - - expect(receivedA).toHaveLength(1); - expect(receivedB).toHaveLength(0); - - await transportA.close(); - await transportB.close(); - }); - }); -}); diff --git a/src/message-transport.ts b/src/message-transport.ts index afc85e9..0b53d9a 100644 --- a/src/message-transport.ts +++ b/src/message-transport.ts @@ -1,11 +1,12 @@ -import { JSONRPCMessageSchema } from "@modelcontextprotocol/core"; -import type { +import { JSONRPCMessage, + JSONRPCMessageSchema, MessageExtraInfo, +} from "@modelcontextprotocol/sdk/types.js"; +import { Transport, TransportSendOptions, -} from "@modelcontextprotocol/client"; -import { TOOL_INPUT_PARTIAL_METHOD } from "./spec.types.js"; +} from "@modelcontextprotocol/sdk/shared/transport.js"; /** * JSON-RPC transport using `window.postMessage` for iframeโ†”parent communication. @@ -17,29 +18,29 @@ import { TOOL_INPUT_PARTIAL_METHOD } from "./spec.types.js"; * ## Security * * The `eventSource` parameter is required and validates the message source window - * by checking `event.source`. For views, pass `window.parent`. + * by checking `event.source`. For guest UIs, pass `window.parent`. * For hosts, pass `iframe.contentWindow` to validate the iframe source. * * ## Usage * - * **View**: - * ```ts source="./message-transport.examples.ts#PostMessageTransport_view" + * **Guest UI**: + * ```typescript * const transport = new PostMessageTransport(window.parent, window.parent); * await app.connect(transport); * ``` * * **Host**: - * ```ts source="./message-transport.examples.ts#PostMessageTransport_host" - * const iframe = document.getElementById("app-iframe") as HTMLIFrameElement; + * ```typescript + * const iframe = document.getElementById('app-iframe') as HTMLIFrameElement; * const transport = new PostMessageTransport( * iframe.contentWindow!, - * iframe.contentWindow!, + * iframe.contentWindow! * ); * await bridge.connect(transport); * ``` * - * @see {@link app!App.connect `App.connect`} for View usage - * @see {@link app-bridge!AppBridge.connect `AppBridge.connect`} for Host usage + * @see {@link app!App.connect} for Guest UI usage + * @see {@link app-bridge!AppBridge.connect} for Host usage */ export class PostMessageTransport implements Transport { private messageListener: ( @@ -51,20 +52,20 @@ export class PostMessageTransport implements Transport { * Create a new PostMessageTransport. * * @param eventTarget - Target window to send messages to (default: `window.parent`) - * @param eventSource - Source window for message validation. For views, pass + * @param eventSource - Source window for message validation. For guests, pass * `window.parent`. For hosts, pass `iframe.contentWindow`. * - * @example View connecting to parent - * ```ts source="./message-transport.examples.ts#PostMessageTransport_constructor_view" + * @example Guest UI connecting to parent + * ```typescript * const transport = new PostMessageTransport(window.parent, window.parent); * ``` * * @example Host connecting to iframe - * ```ts source="./message-transport.examples.ts#PostMessageTransport_constructor_host" - * const iframe = document.getElementById("app-iframe") as HTMLIFrameElement; + * ```typescript + * const iframe = document.getElementById('app') as HTMLIFrameElement; * const transport = new PostMessageTransport( * iframe.contentWindow!, - * iframe.contentWindow!, + * iframe.contentWindow! * ); * ``` */ @@ -81,17 +82,7 @@ export class PostMessageTransport implements Transport { if (parsed.success) { console.debug("Parsed message", parsed.data); this.onmessage?.(parsed.data); - } else if (event.data?.jsonrpc !== "2.0") { - // Not a JSON-RPC message at all (e.g. internal frames injected by - // the host environment). Ignore silently so the transport stays alive. - console.debug( - "Ignoring non-JSON-RPC message", - parsed.error.message, - event, - ); } else { - // Has jsonrpc: "2.0" but is otherwise malformed โ€” surface as a real - // protocol error. console.error("Failed to parse message", parsed.error.message, event); this.onerror?.( new Error( @@ -122,18 +113,14 @@ export class PostMessageTransport implements Transport { * @param options - Optional send options (currently unused) */ async send(message: JSONRPCMessage, options?: TransportSendOptions) { - // Skip debug log for high-frequency streaming notifications โ€” these - // can fire dozens of times per second and flood the console. - if ((message as { method?: string }).method !== TOOL_INPUT_PARTIAL_METHOD) { - console.debug("Sending message", message); - } + console.debug("Sending message", message); this.eventTarget.postMessage(message, "*"); } /** * Stop listening for messages and cleanup. * - * Removes the message event listener and calls the {@link onclose `onclose`} callback if set. + * Removes the message event listener and calls the {@link onclose} callback if set. */ async close() { window.removeEventListener("message", this.messageListener); @@ -143,7 +130,7 @@ export class PostMessageTransport implements Transport { /** * Called when the transport is closed. * - * Set this handler to be notified when {@link close `close`} is called. + * Set this handler to be notified when {@link close} is called. */ onclose?: () => void; @@ -160,7 +147,7 @@ export class PostMessageTransport implements Transport { /** * Called when a valid JSON-RPC message is received. * - * This handler is invoked after message validation succeeds. The {@link start `start`} + * This handler is invoked after message validation succeeds. The {@link start} * method must be called before messages will be received. * * @param message - The validated JSON-RPC message diff --git a/src/react/index.examples.tsx b/src/react/index.examples.tsx deleted file mode 100644 index 3497a85..0000000 --- a/src/react/index.examples.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Type-checked examples for the React module overview. - * - * @module - */ - -import { useApp } from "./index.js"; - -/** - * Example: Basic React App from module overview. - */ -function index_basicReactApp() { - //#region index_basicReactApp - function MyApp() { - const { app, isConnected, error } = useApp({ - appInfo: { name: "MyApp", version: "1.0.0" }, - capabilities: {}, - }); - - if (error) return <div>Error: {error.message}</div>; - if (!isConnected) return <div>Connecting...</div>; - - return <div>Connected!</div>; - } - //#endregion index_basicReactApp -} diff --git a/src/react/index.tsx b/src/react/index.tsx index 9693fb1..4307dc5 100644 --- a/src/react/index.tsx +++ b/src/react/index.tsx @@ -3,25 +3,27 @@ * * This module provides React hooks and utilities for easily building * interactive MCP Apps using React. This is optional - the core SDK - * ({@link App `App`}, {@link PostMessageTransport `PostMessageTransport`}) is framework-agnostic and can be + * ({@link App}, {@link PostMessageTransport}) is framework-agnostic and can be * used with any UI framework or vanilla JavaScript. * * ## Main Exports * - * - {@link useApp `useApp`} - React hook to create and connect an MCP App - * - {@link useHostStyleVariables `useHostStyleVariables`} - React hook to apply host style variables and theme - * - {@link useHostFonts `useHostFonts`} - React hook to apply host fonts - * - {@link useDocumentTheme `useDocumentTheme`} - React hook for reactive document theme - * - {@link useAutoResize `useAutoResize`} - React hook for manual auto-resize control (rarely needed) + * - {@link useApp} - React hook to create and connect an MCP App + * - {@link useHostStyleVariables} - React hook to apply host style variables and theme + * - {@link useHostFonts} - React hook to apply host fonts + * - {@link useDocumentTheme} - React hook for reactive document theme + * - {@link useAutoResize} - React hook for manual auto-resize control (rarely needed) * * @module @modelcontextprotocol/ext-apps/react * * @example Basic React App - * ```tsx source="./index.examples.tsx#index_basicReactApp" + * ```tsx + * import { useApp } from '@modelcontextprotocol/ext-apps/react'; + * * function MyApp() { * const { app, isConnected, error } = useApp({ * appInfo: { name: "MyApp", version: "1.0.0" }, - * capabilities: {}, + * capabilities: {} * }); * * if (error) return <div>Error: {error.message}</div>; @@ -31,7 +33,7 @@ * } * ``` */ -export * from "./useApp.js"; -export * from "./useAutoResize.js"; -export * from "./useDocumentTheme.js"; -export * from "./useHostStyles.js"; +export * from "./useApp"; +export * from "./useAutoResize"; +export * from "./useDocumentTheme"; +export * from "./useHostStyles"; diff --git a/src/react/useApp.examples.tsx b/src/react/useApp.examples.tsx deleted file mode 100644 index afa54c9..0000000 --- a/src/react/useApp.examples.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Type-checked examples for the useApp hook. - * - * @module - */ - -import { useState } from "react"; -import type { McpUiHostContext } from "../types.js"; -import { useApp } from "./index.js"; - -/** - * Example: Register an event handler in onAppCreated. - */ -function useApp_registerHandler() { - //#region useApp_registerHandler - useApp({ - appInfo: { name: "MyApp", version: "1.0.0" }, - capabilities: {}, - onAppCreated: (app) => { - app.ontoolresult = (result) => { - console.log("Tool result:", result); - }; - }, - }); - //#endregion useApp_registerHandler -} - -/** - * Example: Basic usage of useApp hook with common event handlers. - */ -function useApp_basicUsage() { - //#region useApp_basicUsage - function MyApp() { - const [hostContext, setHostContext] = useState< - McpUiHostContext | undefined - >(undefined); - - const { app, isConnected, error } = useApp({ - appInfo: { name: "MyApp", version: "1.0.0" }, - capabilities: {}, - onAppCreated: (app) => { - app.ontoolinput = (input) => { - console.log("Tool input:", input); - }; - app.ontoolresult = (result) => { - console.log("Tool result:", result); - }; - app.ontoolcancelled = (params) => { - console.log("Tool cancelled:", params.reason); - }; - app.onerror = (error) => { - console.log("Error:", error); - }; - app.onhostcontextchanged = (ctx) => { - setHostContext((prev) => ({ ...prev, ...ctx })); - }; - }, - }); - - if (error) return <div>Error: {error.message}</div>; - if (!isConnected) return <div>Connecting...</div>; - return <div>Theme: {hostContext?.theme}</div>; - } - //#endregion useApp_basicUsage -} diff --git a/src/react/useApp.tsx b/src/react/useApp.tsx index a8e1a09..65b93da 100644 --- a/src/react/useApp.tsx +++ b/src/react/useApp.tsx @@ -1,27 +1,20 @@ import { useEffect, useState } from "react"; -import type { Implementation } from "@modelcontextprotocol/client"; -import { - App, - AppOptions, - McpUiAppCapabilities, - PostMessageTransport, -} from "../app.js"; -export * from "../app.js"; +import { Implementation } from "@modelcontextprotocol/sdk/types.js"; +import { Client } from "@modelcontextprotocol/sdk/client"; +import { App, McpUiAppCapabilities, PostMessageTransport } from "../app"; +export * from "../app"; /** - * Options for configuring the {@link useApp `useApp`} hook. + * Options for configuring the {@link useApp} hook. * - * The `autoResize` and `strict` options are forwarded to the underlying - * {@link App `App`} instance. For other {@link AppOptions `AppOptions`}, - * create the `App` manually instead of using this hook. + * Note: This interface does NOT expose {@link App} options like `autoResize`. + * The hook creates the `App` with default options (`autoResize: true`). If you + * need custom `App` options, create the `App` manually instead of using this hook. * - * @see {@link useApp `useApp`} for the hook that uses these options - * @see {@link useAutoResize `useAutoResize`} for manual auto-resize control with custom `App` options + * @see {@link useApp} for the hook that uses these options + * @see {@link useAutoResize} for manual auto-resize control with custom `App` options */ -export interface UseAppOptions extends Pick< - AppOptions, - "autoResize" | "strict" -> { +export interface UseAppOptions { /** App identification (name and version) */ appInfo: Implementation; /** @@ -29,34 +22,35 @@ export interface UseAppOptions extends Pick< */ capabilities: McpUiAppCapabilities; /** - * Called after {@link App `App`} is created but before connection. + * Called after {@link App} is created but before connection. * * Use this to register request/notification handlers that need to be in place * before the initialization handshake completes. * * @param app - The newly created `App` instance * - * @example Register an event handler - * ```tsx source="./useApp.examples.tsx#useApp_registerHandler" - * useApp({ - * appInfo: { name: "MyApp", version: "1.0.0" }, - * capabilities: {}, - * onAppCreated: (app) => { - * app.ontoolresult = (result) => { - * console.log("Tool result:", result); - * }; - * }, - * }); + * @example Register a notification handler + * ```typescript + * import { McpUiToolInputNotificationSchema } from '@modelcontextprotocol/ext-apps/react'; + * + * onAppCreated: (app) => { + * app.setNotificationHandler( + * McpUiToolInputNotificationSchema, + * (notification) => { + * console.log("Tool input:", notification.params.arguments); + * } + * ); + * } * ``` */ onAppCreated?: (app: App) => void; } /** - * State returned by the {@link useApp `useApp`} hook. + * State returned by the {@link useApp} hook. */ export interface AppState { - /** The connected {@link App `App`} instance, null during initialization */ + /** The connected {@link App} instance, null during initialization */ app: App | null; /** Whether initialization completed successfully */ isConnected: boolean; @@ -67,8 +61,8 @@ export interface AppState { /** * React hook to create and connect an MCP App. * - * This hook manages {@link App `App`} creation and connection. It automatically - * creates a {@link PostMessageTransport `PostMessageTransport`} to window.parent and handles + * This hook manages {@link App} creation and connection. It automatically + * creates a {@link PostMessageTransport} to window.parent and handles * initialization. * * This hook is part of the optional React integration. The core SDK (`App`, @@ -79,57 +73,45 @@ export interface AppState { * to avoid reconnection loops. Options are only used during the initial mount. * Furthermore, the `App` instance is NOT closed on unmount. This avoids cleanup * issues during React Strict Mode's double-mount cycle. If you need to - * explicitly close the `App`, call {@link App.close `App.close`} manually. + * explicitly close the `App`, call {@link App.close} manually. * * @param options - Configuration for the app * @returns Current connection state and app instance. If connection fails during * initialization, the `error` field will contain the error (typically connection * timeouts, initialization handshake failures, or transport errors). * - * @example Basic usage of useApp hook with common event handlers - * ```tsx source="./useApp.examples.tsx#useApp_basicUsage" - * function MyApp() { - * const [hostContext, setHostContext] = useState< - * McpUiHostContext | undefined - * >(undefined); + * @example Basic usage + * ```typescript + * import { useApp, McpUiToolInputNotificationSchema } from '@modelcontextprotocol/ext-apps/react'; * + * function MyApp() { * const { app, isConnected, error } = useApp({ * appInfo: { name: "MyApp", version: "1.0.0" }, * capabilities: {}, * onAppCreated: (app) => { - * app.ontoolinput = (input) => { - * console.log("Tool input:", input); - * }; - * app.ontoolresult = (result) => { - * console.log("Tool result:", result); - * }; - * app.ontoolcancelled = (params) => { - * console.log("Tool cancelled:", params.reason); - * }; - * app.onerror = (error) => { - * console.log("Error:", error); - * }; - * app.onhostcontextchanged = (ctx) => { - * setHostContext((prev) => ({ ...prev, ...ctx })); - * }; + * // Register handlers before connection + * app.setNotificationHandler( + * McpUiToolInputNotificationSchema, + * (notification) => { + * console.log("Tool input:", notification.params.arguments); + * } + * ); * }, * }); * * if (error) return <div>Error: {error.message}</div>; * if (!isConnected) return <div>Connecting...</div>; - * return <div>Theme: {hostContext?.theme}</div>; + * return <div>Connected!</div>; * } * ``` * - * @see {@link App.connect `App.connect`} for the underlying connection method - * @see {@link useAutoResize `useAutoResize`} for manual auto-resize control when using custom App options + * @see {@link App.connect} for the underlying connection method + * @see {@link useAutoResize} for manual auto-resize control when using custom App options */ export function useApp({ appInfo, capabilities, onAppCreated, - autoResize = true, - strict, }: UseAppOptions): AppState { const [app, setApp] = useState<App | null>(null); const [isConnected, setIsConnected] = useState(false); @@ -137,7 +119,6 @@ export function useApp({ useEffect(() => { let mounted = true; - let appInstance: App | undefined; async function connect() { try { @@ -145,24 +126,18 @@ export function useApp({ window.parent, window.parent, ); - appInstance = new App(appInfo, capabilities, { autoResize, strict }); + const app = new App(appInfo, capabilities); // Register handlers BEFORE connecting - onAppCreated?.(appInstance); + onAppCreated?.(app); - await appInstance.connect(transport); + await app.connect(transport); - if (!mounted) { - // Unmounted during the handshake (e.g. React StrictMode dev - // double-invoke). Close so the abandoned transport's window - // `message` listener doesn't keep receiving host postMessages - // alongside the second mount's instance. - void appInstance.close(); - return; + if (mounted) { + setApp(app); + setIsConnected(true); + setError(null); } - setApp(appInstance); - setIsConnected(true); - setError(null); } catch (error) { if (mounted) { setApp(null); @@ -178,7 +153,6 @@ export function useApp({ return () => { mounted = false; - void appInstance?.close(); }; }, []); // Intentionally not including options to avoid reconnection diff --git a/src/react/useAutoResize.examples.tsx b/src/react/useAutoResize.examples.tsx deleted file mode 100644 index e132d77..0000000 --- a/src/react/useAutoResize.examples.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Type-checked examples for the useAutoResize hook. - * - * @module - */ - -import { useState, useEffect } from "react"; -import { App, PostMessageTransport } from "./index.js"; -import { useAutoResize } from "./useAutoResize.js"; - -/** - * Example: Manual App creation with custom auto-resize control. - */ -function useAutoResize_manualApp() { - //#region useAutoResize_manualApp - function MyComponent() { - // For custom App options, create App manually instead of using useApp - const [app, setApp] = useState<App | null>(null); - const [error, setError] = useState<Error | null>(null); - - useEffect(() => { - const myApp = new App( - { name: "MyApp", version: "1.0.0" }, - {}, // capabilities - { autoResize: false }, // Disable default auto-resize - ); - - const transport = new PostMessageTransport(window.parent, window.parent); - myApp - .connect(transport) - .then(() => setApp(myApp)) - .catch((err) => setError(err)); - }, []); - - // Add manual auto-resize control - useAutoResize(app); - - if (error) return <div>Connection failed: {error.message}</div>; - return <div>My content</div>; - } - //#endregion useAutoResize_manualApp -} diff --git a/src/react/useAutoResize.ts b/src/react/useAutoResize.ts index 66af07e..4c83eff 100644 --- a/src/react/useAutoResize.ts +++ b/src/react/useAutoResize.ts @@ -1,5 +1,5 @@ import { useEffect, RefObject } from "react"; -import { App } from "../app.js"; +import { App } from "../app"; /** * React hook that automatically reports UI size changes to the host. @@ -9,18 +9,18 @@ import { App } from "../app.js"; * * The hook automatically cleans up the `ResizeObserver` when the component unmounts. * - * **Note**: This hook is rarely needed since the {@link useApp `useApp`} hook automatically enables + * **Note**: This hook is rarely needed since the {@link useApp} hook automatically enables * auto-resize by default. This hook is provided for advanced cases where you - * create the {@link App `App`} manually with `autoResize: false` and want to add auto-resize + * create the {@link App} manually with `autoResize: false` and want to add auto-resize * behavior later. * - * @param app - The connected {@link App `App`} instance, or null during initialization + * @param app - The connected {@link App} instance, or null during initialization * @param elementRef - Currently unused. The hook always observes `document.body` * and `document.documentElement` regardless of this value. Passing a ref will * cause unnecessary effect re-runs; omit this parameter. * * @example Manual App creation with custom auto-resize control - * ```tsx source="./useAutoResize.examples.tsx#useAutoResize_manualApp" + * ```tsx * function MyComponent() { * // For custom App options, create App manually instead of using useApp * const [app, setApp] = useState<App | null>(null); @@ -30,12 +30,11 @@ import { App } from "../app.js"; * const myApp = new App( * { name: "MyApp", version: "1.0.0" }, * {}, // capabilities - * { autoResize: false }, // Disable default auto-resize + * { autoResize: false } // Disable default auto-resize * ); * * const transport = new PostMessageTransport(window.parent, window.parent); - * myApp - * .connect(transport) + * myApp.connect(transport) * .then(() => setApp(myApp)) * .catch((err) => setError(err)); * }, []); @@ -48,9 +47,9 @@ import { App } from "../app.js"; * } * ``` * - * @see {@link App.setupSizeChangedNotifications `App.setupSizeChangedNotifications`} for the underlying implementation - * @see {@link useApp `useApp`} which enables auto-resize by default - * @see {@link App `App`} constructor for configuring `autoResize` option + * @see {@link App.setupSizeChangedNotifications} for the underlying implementation + * @see {@link useApp} which enables auto-resize by default + * @see {@link App} constructor for configuring `autoResize` option */ export function useAutoResize( app: App | null, diff --git a/src/react/useDocumentTheme.examples.tsx b/src/react/useDocumentTheme.examples.tsx deleted file mode 100644 index a0474ce..0000000 --- a/src/react/useDocumentTheme.examples.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Type-checked examples for the useDocumentTheme hook. - * - * @module - */ - -import { useDocumentTheme } from "./useDocumentTheme.js"; - -// Stub components for examples -declare function DarkIcon(): React.JSX.Element; -declare function LightIcon(): React.JSX.Element; - -/** - * Example: Conditionally render based on theme. - */ -function useDocumentTheme_conditionalRender() { - //#region useDocumentTheme_conditionalRender - function MyApp() { - const theme = useDocumentTheme(); - - return <div>{theme === "dark" ? <DarkIcon /> : <LightIcon />}</div>; - } - //#endregion useDocumentTheme_conditionalRender -} - -/** - * Example: Use with theme-aware styling. - */ -function useDocumentTheme_themedButton() { - //#region useDocumentTheme_themedButton - function ThemedButton() { - const theme = useDocumentTheme(); - - return ( - <button - style={{ - background: theme === "dark" ? "#333" : "#fff", - color: theme === "dark" ? "#fff" : "#333", - }} - > - Click me - </button> - ); - } - //#endregion useDocumentTheme_themedButton -} diff --git a/src/react/useDocumentTheme.ts b/src/react/useDocumentTheme.ts index 3dc851e..a04c6e4 100644 --- a/src/react/useDocumentTheme.ts +++ b/src/react/useDocumentTheme.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { getDocumentTheme } from "../styles.js"; -import { McpUiTheme } from "../types.js"; +import { getDocumentTheme } from "../styles"; +import { McpUiTheme } from "../types"; /** * React hook that provides the current document theme reactively. @@ -15,34 +15,38 @@ import { McpUiTheme } from "../types.js"; * @returns The current theme ("light" or "dark") * * @example Conditionally render based on theme - * ```tsx source="./useDocumentTheme.examples.tsx#useDocumentTheme_conditionalRender" + * ```tsx + * import { useDocumentTheme } from '@modelcontextprotocol/ext-apps/react'; + * * function MyApp() { * const theme = useDocumentTheme(); * - * return <div>{theme === "dark" ? <DarkIcon /> : <LightIcon />}</div>; + * return ( + * <div> + * {theme === 'dark' ? <DarkIcon /> : <LightIcon />} + * </div> + * ); * } * ``` * * @example Use with theme-aware styling - * ```tsx source="./useDocumentTheme.examples.tsx#useDocumentTheme_themedButton" + * ```tsx * function ThemedButton() { * const theme = useDocumentTheme(); * * return ( - * <button - * style={{ - * background: theme === "dark" ? "#333" : "#fff", - * color: theme === "dark" ? "#fff" : "#333", - * }} - * > + * <button style={{ + * background: theme === 'dark' ? '#333' : '#fff', + * color: theme === 'dark' ? '#fff' : '#333', + * }}> * Click me * </button> * ); * } * ``` * - * @see {@link getDocumentTheme `getDocumentTheme`} for the underlying function - * @see {@link applyDocumentTheme `applyDocumentTheme`} to set the theme + * @see {@link getDocumentTheme} for the underlying function + * @see {@link applyDocumentTheme} to set the theme */ export function useDocumentTheme(): McpUiTheme { const [theme, setTheme] = useState<McpUiTheme>(getDocumentTheme); diff --git a/src/react/useHostStyles.examples.tsx b/src/react/useHostStyles.examples.tsx deleted file mode 100644 index 48d8364..0000000 --- a/src/react/useHostStyles.examples.tsx +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Type-checked examples for the useHostStyles hooks. - * - * @module - */ - -import { - useApp, - useHostStyleVariables, - useHostFonts, - useHostStyles, -} from "./index.js"; - -/** - * Example: Basic usage of useHostStyleVariables. - */ -function useHostStyleVariables_basicUsage() { - //#region useHostStyleVariables_basicUsage - function MyApp() { - const { app } = useApp({ - appInfo: { name: "MyApp", version: "1.0.0" }, - capabilities: {}, - }); - - // Apply host styles - pass initial context to apply styles from connect() immediately - useHostStyleVariables(app, app?.getHostContext()); - - return ( - <div style={{ background: "var(--color-background-primary)" }}> - Hello! - </div> - ); - } - //#endregion useHostStyleVariables_basicUsage -} - -/** - * Example: Basic usage of useHostFonts with useApp. - */ -function useHostFonts_basicUsage() { - //#region useHostFonts_basicUsage - function MyApp() { - const { app } = useApp({ - appInfo: { name: "MyApp", version: "1.0.0" }, - capabilities: {}, - }); - - // Apply host fonts - pass initial context to apply fonts from connect() immediately - useHostFonts(app, app?.getHostContext()); - - return <div style={{ fontFamily: "var(--font-sans)" }}>Hello!</div>; - } - //#endregion useHostFonts_basicUsage -} - -/** - * Example: Basic usage of useHostStyles. - */ -function useHostStyles_basicUsage() { - //#region useHostStyles_basicUsage - function MyApp() { - const { app } = useApp({ - appInfo: { name: "MyApp", version: "1.0.0" }, - capabilities: {}, - }); - - // Apply all host styles - pass initial context to apply styles from connect() immediately - useHostStyles(app, app?.getHostContext()); - - return ( - <div style={{ background: "var(--color-background-primary)" }}> - Hello! - </div> - ); - } - //#endregion useHostStyles_basicUsage -} diff --git a/src/react/useHostStyles.ts b/src/react/useHostStyles.ts index d8376a0..26006bc 100644 --- a/src/react/useHostStyles.ts +++ b/src/react/useHostStyles.ts @@ -1,11 +1,11 @@ import { useEffect, useRef } from "react"; -import { App } from "../app.js"; +import { App } from "../app"; import { applyDocumentTheme, applyHostFonts, applyHostStyleVariables, -} from "../styles.js"; -import { McpUiHostContext } from "../types.js"; +} from "../styles"; +import { McpUiHostContext } from "../types"; /** * React hook that applies host style variables and theme as CSS custom properties. @@ -21,14 +21,16 @@ import { McpUiHostContext } from "../types.js"; * this hook ensures they work correctly by setting the `color-scheme` property * based on the host's theme preference. * - * @param app - The connected {@link App `App`} instance, or null during initialization + * @param app - The connected {@link App} instance, or null during initialization * @param initialContext - Initial host context from the connection (optional). * If provided, styles and theme will be applied immediately on mount. * * @example - * ```tsx source="./useHostStyles.examples.tsx#useHostStyleVariables_basicUsage" + * ```tsx + * import { useApp, useHostStyleVariables } from '@modelcontextprotocol/ext-apps/react'; + * * function MyApp() { - * const { app } = useApp({ + * const { app, isConnected } = useApp({ * appInfo: { name: "MyApp", version: "1.0.0" }, * capabilities: {}, * }); @@ -37,17 +39,17 @@ import { McpUiHostContext } from "../types.js"; * useHostStyleVariables(app, app?.getHostContext()); * * return ( - * <div style={{ background: "var(--color-background-primary)" }}> + * <div style={{ background: 'var(--color-background-primary)' }}> * Hello! * </div> * ); * } * ``` * - * @see {@link applyHostStyleVariables `applyHostStyleVariables`} for the underlying styles function - * @see {@link applyDocumentTheme `applyDocumentTheme`} for the underlying theme function - * @see {@link useHostFonts `useHostFonts`} for applying host fonts - * @see {@link McpUiStyles `McpUiStyles`} for available CSS variables + * @see {@link applyHostStyleVariables} for the underlying styles function + * @see {@link applyDocumentTheme} for the underlying theme function + * @see {@link useHostFonts} for applying host fonts + * @see {@link McpUiStyles} for available CSS variables */ export function useHostStyleVariables( app: App | null, @@ -77,7 +79,7 @@ export function useHostStyleVariables( return; } - const listener = (params: McpUiHostContext) => { + app.onhostcontextchanged = (params) => { if (params.theme) { applyDocumentTheme(params.theme); } @@ -85,8 +87,6 @@ export function useHostStyleVariables( applyHostStyleVariables(params.styles.variables); } }; - app.addEventListener("hostcontextchanged", listener); - return () => app.removeEventListener("hostcontextchanged", listener); }, [app]); } @@ -102,27 +102,43 @@ export function useHostStyleVariables( * The hook also applies fonts from the initial host context when * the app first connects. * - * @param app - The connected {@link App `App`} instance, or null during initialization + * @param app - The connected {@link App} instance, or null during initialization * @param initialContext - Initial host context from the connection (optional). * If provided, fonts will be applied immediately on mount. * * @example Basic usage with useApp - * ```tsx source="./useHostStyles.examples.tsx#useHostFonts_basicUsage" + * ```tsx + * import { useApp } from '@modelcontextprotocol/ext-apps/react'; + * import { useHostFonts } from '@modelcontextprotocol/ext-apps/react'; + * * function MyApp() { - * const { app } = useApp({ + * const { app, isConnected } = useApp({ * appInfo: { name: "MyApp", version: "1.0.0" }, * capabilities: {}, * }); * - * // Apply host fonts - pass initial context to apply fonts from connect() immediately - * useHostFonts(app, app?.getHostContext()); + * // Automatically apply host fonts + * useHostFonts(app); * - * return <div style={{ fontFamily: "var(--font-sans)" }}>Hello!</div>; + * return ( + * <div style={{ fontFamily: 'var(--font-sans)' }}> + * Hello! + * </div> + * ); * } * ``` * - * @see {@link applyHostFonts `applyHostFonts`} for the underlying fonts function - * @see {@link useHostStyleVariables `useHostStyleVariables`} for applying style variables and theme + * @example With initial context + * ```tsx + * const [hostContext, setHostContext] = useState<McpUiHostContext | null>(null); + * + * // ... get initial context from app.connect() result + * + * useHostFonts(app, hostContext); + * ``` + * + * @see {@link applyHostFonts} for the underlying fonts function + * @see {@link useHostStyleVariables} for applying style variables and theme */ export function useHostFonts( app: App | null, @@ -147,47 +163,36 @@ export function useHostFonts( return; } - const listener = (params: McpUiHostContext) => { + app.onhostcontextchanged = (params) => { if (params.styles?.css?.fonts) { applyHostFonts(params.styles.css.fonts); } }; - app.addEventListener("hostcontextchanged", listener); - return () => app.removeEventListener("hostcontextchanged", listener); }, [app]); } /** * Applies all host styling (CSS variables, theme, and fonts) to match the host application. * - * This is a convenience hook that combines {@link useHostStyleVariables `useHostStyleVariables`} and - * {@link useHostFonts `useHostFonts`}. Use the individual hooks if you need more control. + * This is a convenience hook that combines {@link useHostStyleVariables} and + * {@link useHostFonts}. Use the individual hooks if you need more control. * - * @param app - The connected {@link App `App`} instance, or null during initialization + * @param app - The connected {@link App} instance, or null during initialization * @param initialContext - Initial host context from the connection (optional). * Pass `app?.getHostContext()` to apply styles immediately on mount. * * @example - * ```tsx source="./useHostStyles.examples.tsx#useHostStyles_basicUsage" + * ```tsx * function MyApp() { - * const { app } = useApp({ - * appInfo: { name: "MyApp", version: "1.0.0" }, - * capabilities: {}, - * }); - * - * // Apply all host styles - pass initial context to apply styles from connect() immediately + * const { app } = useApp({ appInfo, capabilities: {} }); * useHostStyles(app, app?.getHostContext()); * - * return ( - * <div style={{ background: "var(--color-background-primary)" }}> - * Hello! - * </div> - * ); + * return <div style={{ background: 'var(--color-background-primary)' }}>...</div>; * } * ``` * - * @see {@link useHostStyleVariables `useHostStyleVariables`} for style variables and theme only - * @see {@link useHostFonts `useHostFonts`} for fonts only + * @see {@link useHostStyleVariables} for style variables and theme only + * @see {@link useHostFonts} for fonts only */ export function useHostStyles( app: App | null, diff --git a/src/server/index.examples.ts b/src/server/index.examples.ts deleted file mode 100644 index 9ceebce..0000000 --- a/src/server/index.examples.ts +++ /dev/null @@ -1,288 +0,0 @@ -/** - * Type-checked examples for {@link registerAppTool `registerAppTool`} and {@link registerAppResource `registerAppResource`}. - * - * These examples are included in the API documentation via `@includeCode` tags. - * Each function's region markers define the code snippet that appears in the docs. - * - * @module - */ - -import * as crypto from "node:crypto"; -import * as fs from "node:fs/promises"; -import type { McpServer, ToolCallback } from "@modelcontextprotocol/server"; -import { z } from "zod"; -import { - registerAppTool, - registerAppResource, - getUiCapability, - RESOURCE_MIME_TYPE, - type ReadResourceCallback, -} from "./index.js"; - -// Stubs for external functions used in examples -declare function fetchWeather( - location: string, -): Promise<{ temp: number; conditions: string }>; -declare function getCart(): Promise<{ items: unknown[]; total: number }>; -declare function updateCartItem( - itemId: string, - quantity: number, -): Promise<{ items: unknown[]; total: number }>; - -/** - * Example: Module overview showing basic registration of tools and resources. - */ -function index_overview( - server: McpServer, - toolCallback: ToolCallback, - readCallback: ReadResourceCallback, -) { - //#region index_overview - // Register a tool that displays a view - registerAppTool( - server, - "weather", - { - description: "Get weather forecast", - _meta: { ui: { resourceUri: "ui://weather/view.html" } }, - }, - toolCallback, - ); - - // Register the HTML resource the tool references - registerAppResource( - server, - "Weather View", - "ui://weather/view.html", - {}, - readCallback, - ); - //#endregion index_overview -} - -/** - * Example: Basic usage of registerAppTool with input schema and handler. - */ -function registerAppTool_basicUsage(server: McpServer) { - //#region registerAppTool_basicUsage - registerAppTool( - server, - "get-weather", - { - title: "Get Weather", - description: "Get current weather for a location", - inputSchema: z.object({ location: z.string() }), - _meta: { - ui: { resourceUri: "ui://weather/view.html" }, - }, - }, - async (args) => { - const weather = await fetchWeather(args.location); - return { content: [{ type: "text", text: JSON.stringify(weather) }] }; - }, - ); - //#endregion registerAppTool_basicUsage -} - -/** - * Example: Model-only visibility - tools visible to model but not callable by UI. - */ -function registerAppTool_modelOnlyVisibility(server: McpServer) { - //#region registerAppTool_modelOnlyVisibility - registerAppTool( - server, - "show-cart", - { - description: "Display the user's shopping cart", - _meta: { - ui: { - resourceUri: "ui://shop/cart.html", - visibility: ["model"], - }, - }, - }, - async () => { - const cart = await getCart(); - return { content: [{ type: "text", text: JSON.stringify(cart) }] }; - }, - ); - //#endregion registerAppTool_modelOnlyVisibility -} - -/** - * Example: App-only visibility - tools hidden from model, only callable by UI. - */ -function registerAppTool_appOnlyVisibility(server: McpServer) { - //#region 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) }] }; - }, - ); - //#endregion registerAppTool_appOnlyVisibility -} - -/** - * Example: Basic usage of registerAppResource. - */ -function registerAppResource_basicUsage(server: McpServer) { - //#region registerAppResource_basicUsage - registerAppResource( - server, - "Weather View", - "ui://weather/view.html", - { - description: "Interactive weather display", - }, - async () => ({ - contents: [ - { - uri: "ui://weather/view.html", - mimeType: RESOURCE_MIME_TYPE, - text: await fs.readFile("dist/view.html", "utf-8"), - }, - ], - }), - ); - //#endregion registerAppResource_basicUsage -} - -/** - * Example: registerAppResource with CSP configuration for network access. - */ -function registerAppResource_withCsp( - server: McpServer, - musicPlayerHtml: string, -) { - //#region registerAppResource_withCsp - registerAppResource( - server, - "Music Player", - "ui://music/player.html", - { - description: "Audio player with external soundfonts", - }, - async () => ({ - contents: [ - { - uri: "ui://music/player.html", - mimeType: RESOURCE_MIME_TYPE, - text: musicPlayerHtml, - _meta: { - ui: { - csp: { - resourceDomains: ["https://cdn.example.com"], // For scripts/styles/images - connectDomains: ["https://api.example.com"], // For fetch/WebSocket - }, - }, - }, - }, - ], - }), - ); - //#endregion registerAppResource_withCsp -} - -/** - * Example: registerAppResource with stable origin for external API CORS allowlists. - */ -function registerAppResource_withDomain( - server: McpServer, - dashboardHtml: string, -) { - //#region 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, - }, - }, - }, - ], - }), - ); - //#endregion registerAppResource_withDomain -} - -/** - * Example: Check for MCP Apps support in server initialization. - */ -function getUiCapability_checkSupport( - server: McpServer, - weatherHandler: ToolCallback, - textWeatherHandler: ToolCallback, -) { - //#region getUiCapability_checkSupport - server.server.oninitialized = () => { - const clientCapabilities = server.server.getClientCapabilities(); - const uiCap = getUiCapability(clientCapabilities); - - if (uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) { - // App-enhanced tool - registerAppTool( - server, - "weather", - { - description: "Get weather information with interactive dashboard", - _meta: { ui: { resourceUri: "ui://weather/dashboard" } }, - }, - weatherHandler, - ); - } else { - // Text-only fallback - server.registerTool( - "weather", - { - description: "Get weather information", - }, - textWeatherHandler, - ); - } - }; - //#endregion getUiCapability_checkSupport -} diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 489d0b0..d5e0a80 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -4,16 +4,8 @@ import { registerAppResource, RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE, - getUiCapability, - EXTENSION_ID, -} from "./index.js"; -import { - InMemoryTransport, - McpServer as RealMcpServer, - type McpServer, -} from "@modelcontextprotocol/server"; -import { Client } from "@modelcontextprotocol/client"; -import { z } from "zod/v4"; +} from "./index"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; describe("registerAppTool", () => { it("should pass through config to server.registerTool", () => { @@ -43,7 +35,7 @@ describe("registerAppTool", () => { title: "My Tool", description: "A test tool", _meta: { - [RESOURCE_URI_META_KEY]: "ui://test/view.html", + [RESOURCE_URI_META_KEY]: "ui://test/widget.html", }, }, handler, @@ -57,7 +49,7 @@ describe("registerAppTool", () => { (capturedConfig?._meta as Record<string, unknown>)?.[ RESOURCE_URI_META_KEY ], - ).toBe("ui://test/view.html"); + ).toBe("ui://test/widget.html"); expect(capturedHandler).toBe(handler); }); @@ -82,7 +74,7 @@ describe("registerAppTool", () => { "my-tool", { _meta: { - ui: { resourceUri: "ui://test/view.html" }, + ui: { resourceUri: "ui://test/widget.html" }, }, }, async () => ({ content: [{ type: "text" as const, text: "ok" }] }), @@ -91,10 +83,10 @@ describe("registerAppTool", () => { const meta = capturedConfig?._meta as Record<string, unknown>; // New format should be preserved expect((meta.ui as { resourceUri: string }).resourceUri).toBe( - "ui://test/view.html", + "ui://test/widget.html", ); // Legacy key should also be set - expect(meta[RESOURCE_URI_META_KEY]).toBe("ui://test/view.html"); + expect(meta[RESOURCE_URI_META_KEY]).toBe("ui://test/widget.html"); }); it("should set _meta.ui.resourceUri when legacy key is provided", () => { @@ -117,7 +109,7 @@ describe("registerAppTool", () => { "my-tool", { _meta: { - [RESOURCE_URI_META_KEY]: "ui://test/view.html", + [RESOURCE_URI_META_KEY]: "ui://test/widget.html", }, }, async () => ({ content: [{ type: "text" as const, text: "ok" }] }), @@ -125,10 +117,10 @@ describe("registerAppTool", () => { const meta = capturedConfig?._meta as Record<string, unknown>; // Legacy key should be preserved - expect(meta[RESOURCE_URI_META_KEY]).toBe("ui://test/view.html"); + expect(meta[RESOURCE_URI_META_KEY]).toBe("ui://test/widget.html"); // New format should also be set expect((meta.ui as { resourceUri: string }).resourceUri).toBe( - "ui://test/view.html", + "ui://test/widget.html", ); }); @@ -153,7 +145,7 @@ describe("registerAppTool", () => { { _meta: { ui: { visibility: ["app"] }, - [RESOURCE_URI_META_KEY]: "ui://test/view.html", + [RESOURCE_URI_META_KEY]: "ui://test/widget.html", }, } as any, async () => ({ content: [{ type: "text" as const, text: "ok" }] }), @@ -162,7 +154,7 @@ describe("registerAppTool", () => { const meta = capturedConfig?._meta as Record<string, unknown>; const ui = meta.ui as { resourceUri: string; visibility: string[] }; // Should have merged resourceUri into existing ui object - expect(ui.resourceUri).toBe("ui://test/view.html"); + expect(ui.resourceUri).toBe("ui://test/widget.html"); expect(ui.visibility).toEqual(["app"]); }); @@ -186,8 +178,8 @@ describe("registerAppTool", () => { "my-tool", { _meta: { - ui: { resourceUri: "ui://new/view.html" }, - [RESOURCE_URI_META_KEY]: "ui://old/view.html", + ui: { resourceUri: "ui://new/widget.html" }, + [RESOURCE_URI_META_KEY]: "ui://old/widget.html", }, } as any, async () => ({ content: [{ type: "text" as const, text: "ok" }] }), @@ -196,126 +188,13 @@ describe("registerAppTool", () => { const meta = capturedConfig?._meta as Record<string, unknown>; // Both should remain unchanged expect((meta.ui as { resourceUri: string }).resourceUri).toBe( - "ui://new/view.html", + "ui://new/widget.html", ); - expect(meta[RESOURCE_URI_META_KEY]).toBe("ui://old/view.html"); + expect(meta[RESOURCE_URI_META_KEY]).toBe("ui://old/widget.html"); }); }); }); -describe("registerAppTool schema forms", () => { - /** Registers tools on a fresh server, then connects a client to it. */ - async function connect(register: (server: RealMcpServer) => void) { - const server = new RealMcpServer({ name: "test", version: "0.0.0" }); - register(server); - const client = new Client({ name: "client", version: "0.0.0" }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - await client.connect(clientTransport); - return client; - } - - it("accepts a z.object() input schema and validates arguments", async () => { - const client = await connect((server) => - registerAppTool( - server, - "greet", - { - inputSchema: z.object({ name: z.string() }), - _meta: { ui: { resourceUri: "ui://greet/view.html" } }, - }, - async ({ name }) => ({ - content: [{ type: "text", text: `hello ${name}` }], - }), - ), - ); - - const { tools } = await client.listTools(); - expect(tools).toHaveLength(1); - expect(tools[0].inputSchema).toMatchObject({ - type: "object", - properties: { name: { type: "string" } }, - }); - expect(tools[0]._meta).toMatchObject({ - ui: { resourceUri: "ui://greet/view.html" }, - [RESOURCE_URI_META_KEY]: "ui://greet/view.html", - }); - - const result = await client.callTool({ - name: "greet", - arguments: { name: "world" }, - }); - expect(result.content).toEqual([{ type: "text", text: "hello world" }]); - - const invalid = await client.callTool({ - name: "greet", - arguments: { name: 42 }, - }); - expect(invalid.isError).toBe(true); - }); - - it("accepts a raw zod shape (deprecated form) and wraps it with z.object()", async () => { - const client = await connect((server) => - registerAppTool( - server, - "greet", - { - inputSchema: { name: z.string() }, - outputSchema: { greeting: z.string() }, - _meta: { ui: { resourceUri: "ui://greet/view.html" } }, - }, - async ({ name }) => ({ - content: [{ type: "text", text: `hello ${name}` }], - structuredContent: { greeting: `hello ${name}` }, - }), - ), - ); - - const { tools } = await client.listTools(); - expect(tools[0].inputSchema).toMatchObject({ - type: "object", - properties: { name: { type: "string" } }, - }); - expect(tools[0].outputSchema).toMatchObject({ - type: "object", - properties: { greeting: { type: "string" } }, - }); - - const result = await client.callTool({ - name: "greet", - arguments: { name: "world" }, - }); - expect(result.structuredContent).toEqual({ greeting: "hello world" }); - - const invalid = await client.callTool({ - name: "greet", - arguments: { name: 42 }, - }); - expect(invalid.isError).toBe(true); - }); - - it("accepts the raw-shape form without an input schema", async () => { - const client = await connect((server) => - registerAppTool( - server, - "ping", - { - outputSchema: { ok: z.boolean() }, - _meta: { ui: { resourceUri: "ui://ping/view.html" } }, - }, - async () => ({ - content: [{ type: "text", text: "pong" }], - structuredContent: { ok: true }, - }), - ), - ); - - const result = await client.callTool({ name: "ping", arguments: {} }); - expect(result.structuredContent).toEqual({ ok: true }); - }); -}); - describe("registerAppResource", () => { it("should register a resource with default MIME type", () => { let capturedName: string | undefined; @@ -336,7 +215,7 @@ describe("registerAppResource", () => { const callback = async () => ({ contents: [ { - uri: "ui://test/view.html", + uri: "ui://test/widget.html", mimeType: RESOURCE_MIME_TYPE, text: "<html/>", }, @@ -346,7 +225,7 @@ describe("registerAppResource", () => { registerAppResource( mockServer as unknown as Pick<McpServer, "registerResource">, "My Resource", - "ui://test/view.html", + "ui://test/widget.html", { description: "A test resource", _meta: { ui: {} }, @@ -356,7 +235,7 @@ describe("registerAppResource", () => { expect(mockServer.registerResource).toHaveBeenCalledTimes(1); expect(capturedName).toBe("My Resource"); - expect(capturedUri).toBe("ui://test/view.html"); + expect(capturedUri).toBe("ui://test/widget.html"); expect(capturedConfig?.mimeType).toBe(RESOURCE_MIME_TYPE); expect(capturedConfig?.description).toBe("A test resource"); }); @@ -376,7 +255,7 @@ describe("registerAppResource", () => { registerAppResource( mockServer as unknown as Pick<McpServer, "registerResource">, "My Resource", - "ui://test/view.html", + "ui://test/widget.html", { mimeType: "text/html", _meta: { ui: {} }, @@ -384,7 +263,7 @@ describe("registerAppResource", () => { async () => ({ contents: [ { - uri: "ui://test/view.html", + uri: "ui://test/widget.html", mimeType: "text/html", text: "<html/>", }, @@ -416,7 +295,7 @@ describe("registerAppResource", () => { const expectedResult = { contents: [ { - uri: "ui://test/view.html", + uri: "ui://test/widget.html", mimeType: RESOURCE_MIME_TYPE, text: "<html>content</html>", }, @@ -427,7 +306,7 @@ describe("registerAppResource", () => { registerAppResource( mockServer as unknown as Pick<McpServer, "registerResource">, "My Resource", - "ui://test/view.html", + "ui://test/widget.html", { _meta: { ui: {} } }, callback, ); @@ -439,39 +318,3 @@ describe("registerAppResource", () => { expect(result).toEqual(expectedResult); }); }); - -describe("getUiCapability", () => { - const MIME_TYPE = "text/html;profile=mcp-app"; - - it("should return undefined for null/undefined capabilities", () => { - expect(getUiCapability(null)).toBeUndefined(); - expect(getUiCapability(undefined)).toBeUndefined(); - }); - - it("should return undefined for empty capabilities", () => { - expect(getUiCapability({})).toBeUndefined(); - }); - - it("should return capability from extensions field", () => { - const caps = { - extensions: { - [EXTENSION_ID]: { - mimeTypes: [MIME_TYPE], - }, - }, - }; - const result = getUiCapability(caps); - expect(result).toEqual({ mimeTypes: [MIME_TYPE] }); - }); - - it("should return undefined when extension ID is missing", () => { - const caps = { - extensions: { - "some-other-extension": { - mimeTypes: [MIME_TYPE], - }, - }, - }; - expect(getUiCapability(caps)).toBeUndefined(); - }); -}); diff --git a/src/server/index.ts b/src/server/index.ts index 5fad055..f242e2a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -2,106 +2,59 @@ * Utilities for MCP servers to register tools and resources that display interactive UIs. * * Use these helpers instead of the base SDK's `registerTool` and `registerResource` when - * your tool should render an {@link app!App `App`} in the client. They handle UI metadata normalization - * and provide sensible defaults for the MCP Apps MIME type ({@link RESOURCE_MIME_TYPE `RESOURCE_MIME_TYPE`}). + * your tool should render an {@link app!App} in the client. They handle UI metadata normalization + * and provide sensible defaults for the MCP Apps MIME type ({@link RESOURCE_MIME_TYPE}). * * @module server-helpers * * @example - * ```ts source="./index.examples.ts#index_overview" - * // Register a tool that displays a view - * registerAppTool( - * server, - * "weather", - * { - * description: "Get weather forecast", - * _meta: { ui: { resourceUri: "ui://weather/view.html" } }, - * }, - * toolCallback, - * ); + * ```typescript + * import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server'; + * + * // Register a tool that displays a widget + * registerAppTool(server, "weather", { + * description: "Get weather forecast", + * _meta: { ui: { resourceUri: "ui://weather/widget.html" } }, + * }, handler); * * // Register the HTML resource the tool references - * registerAppResource( - * server, - * "Weather View", - * "ui://weather/view.html", - * {}, - * readCallback, - * ); + * registerAppResource(server, "Weather Widget", "ui://weather/widget.html", {}, readCallback); * ``` */ import { - McpUiResourceCsp, + RESOURCE_URI_META_KEY, + RESOURCE_MIME_TYPE, McpUiResourceMeta, McpUiToolMeta, - McpUiClientCapabilities, -} from "../spec.types.js"; -import { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "../constants.js"; +} from "../app.js"; import type { - CallToolResult, - ClientCapabilities, - InputRequiredResult, + BaseToolCallback, McpServer, - ReadResourceResult, RegisteredTool, ResourceMetadata, - ServerContext, - StandardSchemaWithJSON, - ToolAnnotations, ToolCallback, - ReadResourceCallback as _ReadResourceCallback, - RegisteredResource, -} from "@modelcontextprotocol/server"; -import type { z } from "zod/v4"; + ReadResourceCallback, +} from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { + AnySchema, + ZodRawShapeCompat, +} from "@modelcontextprotocol/sdk/server/zod-compat.js"; +import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; // Re-exports for convenience export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE }; -export type { ResourceMetadata, ToolCallback }; - -/** - * A plain `{ field: z.string() }` record accepted by the deprecated raw-shape - * form of {@link registerAppTool `registerAppTool`}; the SDK auto-wraps it with `z.object()`. - * - * @deprecated Wrap with `z.object({...})` instead. - */ -export type ZodRawShape = Record<string, z.ZodType>; - -/** - * {@link ToolCallback `ToolCallback`} variant used when `inputSchema` is a {@link ZodRawShape `ZodRawShape`}. - * Mirrors the callback type of the SDK's deprecated `registerTool` overload. - * - * @deprecated Wrap with `z.object({...})` instead and use {@link ToolCallback `ToolCallback`}. - */ -export type LegacyToolCallback<Args extends ZodRawShape | undefined> = - Args extends ZodRawShape - ? ( - args: z.infer<z.ZodObject<Args>>, - ctx: ServerContext, - ) => - | CallToolResult - | InputRequiredResult - | Promise<CallToolResult | InputRequiredResult> - : ( - ctx: ServerContext, - ) => - | CallToolResult - | InputRequiredResult - | Promise<CallToolResult | InputRequiredResult>; +export type { ResourceMetadata, ToolCallback, ReadResourceCallback }; /** * Base tool configuration matching the standard MCP server tool options. - * Extended by {@link McpUiAppToolConfig `McpUiAppToolConfig`} to add UI metadata requirements. - * - * `inputSchema`/`outputSchema` accept any Standard JSON Schema (zod v4, ArkType, - * Valibot, ...). A raw zod shape (`{ field: z.string() }`) is still accepted for - * backward compatibility but deprecated. + * Extended by {@link McpUiAppToolConfig} to add UI metadata requirements. */ export interface ToolConfig { title?: string; description?: string; - inputSchema?: ZodRawShape | StandardSchemaWithJSON; - outputSchema?: ZodRawShape | StandardSchemaWithJSON; + inputSchema?: ZodRawShapeCompat | AnySchema; + outputSchema?: ZodRawShapeCompat | AnySchema; annotations?: ToolAnnotations; _meta?: Record<string, unknown>; } @@ -109,12 +62,12 @@ export interface ToolConfig { /** * Configuration for tools that render an interactive UI. * - * Extends {@link ToolConfig `ToolConfig`} with a required `_meta` field that specifies UI metadata. + * Extends {@link ToolConfig} with a required `_meta` field that specifies UI metadata. * The UI resource can be specified in two ways: * - `_meta.ui.resourceUri` (preferred) * - `_meta["ui/resourceUri"]` (deprecated, for backward compatibility) * - * @see {@link registerAppTool `registerAppTool`} for the recommended way to register app tools + * @see {@link registerAppTool} for the recommended way to register app tools */ export interface McpUiAppToolConfig extends ToolConfig { _meta: { @@ -128,7 +81,7 @@ export interface McpUiAppToolConfig extends ToolConfig { * URI of the UI resource to display for this tool. * This is converted to `_meta["ui/resourceUri"]`. * - * @example "ui://weather/view.html" + * @example "ui://weather/widget.html" * * @deprecated Use `_meta.ui.resourceUri` instead. */ @@ -138,24 +91,17 @@ export interface McpUiAppToolConfig extends ToolConfig { } /** - * MCP App Resource configuration for {@link registerAppResource `registerAppResource`}. + * MCP App Resource configuration for {@link registerAppResource}. * * Extends the base MCP SDK `ResourceMetadata` with optional UI metadata * for configuring security policies and rendering preferences. * - * The `_meta.ui` field here is included in the `resources/list` response and serves as - * a static default for hosts to review at connection time. When the `resources/read` - * content item also includes `_meta.ui`, the content-item value takes precedence. - * - * @see {@link registerAppResource `registerAppResource`} for usage + * @see {@link registerAppResource} for usage */ export interface McpUiAppResourceConfig extends ResourceMetadata { /** * Optional UI metadata for the resource. - * - * This appears on the resource entry in `resources/list` and acts as a listing-level - * fallback. Individual content items returned by `resources/read` may include their - * own `_meta.ui` which takes precedence over this value. + * Used to configure security policies (CSP) and rendering preferences. */ _meta?: { /** @@ -180,73 +126,63 @@ export interface McpUiAppResourceConfig extends ResourceMetadata { * @param cb - Tool handler function * * @example Basic usage - * ```ts source="./index.examples.ts#registerAppTool_basicUsage" - * registerAppTool( - * server, - * "get-weather", - * { - * title: "Get Weather", - * description: "Get current weather for a location", - * inputSchema: z.object({ location: z.string() }), - * _meta: { - * ui: { resourceUri: "ui://weather/view.html" }, - * }, + * ```typescript + * import { registerAppTool } from '@modelcontextprotocol/ext-apps/server'; + * import { z } from 'zod'; + * + * registerAppTool(server, "get-weather", { + * title: "Get Weather", + * description: "Get current weather for a location", + * inputSchema: { location: z.string() }, + * _meta: { + * ui: { resourceUri: "ui://weather/widget.html" }, * }, - * async (args) => { - * const weather = await fetchWeather(args.location); - * return { content: [{ type: "text", text: JSON.stringify(weather) }] }; - * }, - * ); + * }, async (args) => { + * const weather = await fetchWeather(args.location); + * return { content: [{ type: "text", text: JSON.stringify(weather) }] }; + * }); * ``` * - * @example Tool visible to model but not callable by UI - * ```ts source="./index.examples.ts#registerAppTool_modelOnlyVisibility" - * registerAppTool( - * server, - * "show-cart", - * { - * description: "Display the user's shopping cart", - * _meta: { - * ui: { - * resourceUri: "ui://shop/cart.html", - * visibility: ["model"], - * }, + * @example Tool visibility - create app-only tools for UI actions + * ```typescript + * import { registerAppTool } from '@modelcontextprotocol/ext-apps/server'; + * import { z } from 'zod'; + * + * // Main tool - visible to both model and app (default) + * registerAppTool(server, "show-cart", { + * description: "Display the user's shopping cart", + * _meta: { + * ui: { + * resourceUri: "ui://shop/cart.html", + * visibility: ["model", "app"], * }, * }, - * async () => { - * const cart = await getCart(); - * return { content: [{ type: "text", text: JSON.stringify(cart) }] }; - * }, - * ); - * ``` + * }, async () => { + * const cart = await getCart(); + * return { content: [{ type: "text", text: JSON.stringify(cart) }] }; + * }); * - * @example Tool hidden from model, only callable by UI - * ```ts source="./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"], - * }, + * // App-only tool - hidden from the model, only callable by the UI + * registerAppTool(server, "update-quantity", { + * description: "Update item quantity in cart", + * inputSchema: { 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) }] }; - * }, - * ); + * }, async ({ itemId, quantity }) => { + * const cart = await updateCartItem(itemId, quantity); + * return { content: [{ type: "text", text: JSON.stringify(cart) }] }; + * }); * ``` * - * @see {@link registerAppResource `registerAppResource`} to register the HTML resource referenced by the tool + * @see {@link registerAppResource} to register the HTML resource referenced by the tool */ export function registerAppTool< - OutputArgs extends StandardSchemaWithJSON, - InputArgs extends StandardSchemaWithJSON | undefined = undefined, + OutputArgs extends ZodRawShapeCompat | AnySchema, + InputArgs extends undefined | ZodRawShapeCompat | AnySchema = undefined, >( server: Pick<McpServer, "registerTool">, name: string, @@ -255,31 +191,6 @@ export function registerAppTool< outputSchema?: OutputArgs; }, cb: ToolCallback<InputArgs>, -): RegisteredTool; -/** - * @deprecated Wrap with `z.object({...})` instead. Raw-shape form: - * `inputSchema`/`outputSchema` may be a plain `{ field: z.string() }` record; - * the SDK auto-wraps it with `z.object()`. - */ -export function registerAppTool< - InputArgs extends ZodRawShape, - OutputArgs extends ZodRawShape | StandardSchemaWithJSON | undefined = - undefined, ->( - server: Pick<McpServer, "registerTool">, - name: string, - config: McpUiAppToolConfig & { - inputSchema?: InputArgs; - outputSchema?: OutputArgs; - }, - cb: LegacyToolCallback<InputArgs>, -): RegisteredTool; -export function registerAppTool( - server: Pick<McpServer, "registerTool">, - name: string, - config: McpUiAppToolConfig, - // Widest callback shape accepted by either overload above. - cb: (...args: never[]) => unknown, ): RegisteredTool { // Normalize metadata for backward compatibility: // - If _meta.ui.resourceUri is set, also set the legacy flat key @@ -297,35 +208,14 @@ export function registerAppTool( normalizedMeta = { ...meta, ui: { ...uiMeta, resourceUri: legacyUri } }; } - // The public overloads above guarantee `config`/`cb` match one of the SDK's - // own registerTool overloads; the union-typed implementation cannot select - // between them, so widen for the forwarding call. - return server.registerTool( - name, - { ...config, _meta: normalizedMeta } as Parameters< - McpServer["registerTool"] - >[1], - cb as ToolCallback<StandardSchemaWithJSON>, - ); + return server.registerTool(name, { ...config, _meta: normalizedMeta }, cb); } -export type McpUiReadResourceResult = ReadResourceResult & { - _meta?: { - ui?: McpUiResourceMeta; - [key: string]: unknown; - }; -}; -export type McpUiReadResourceCallback = ( - uri: URL, - extra: Parameters<_ReadResourceCallback>[1], -) => McpUiReadResourceResult | Promise<McpUiReadResourceResult>; -export type ReadResourceCallback = McpUiReadResourceCallback; - /** * Register an app resource with the MCP server. * * This is a convenience wrapper around `server.registerResource` that: - * - Defaults the MIME type to {@link RESOURCE_MIME_TYPE `RESOURCE_MIME_TYPE`} (`"text/html;profile=mcp-app"`) + * - Defaults the MIME type to {@link RESOURCE_MIME_TYPE} (`"text/html;profile=mcp-app"`) * - Provides a cleaner API matching the SDK's callback signature * * @param server - The MCP server instance @@ -335,113 +225,45 @@ export type ReadResourceCallback = McpUiReadResourceCallback; * @param readCallback - Callback that returns the resource contents * * @example Basic usage - * ```ts source="./index.examples.ts#registerAppResource_basicUsage" - * registerAppResource( - * server, - * "Weather View", - * "ui://weather/view.html", - * { - * description: "Interactive weather display", - * }, - * async () => ({ - * contents: [ - * { - * uri: "ui://weather/view.html", - * mimeType: RESOURCE_MIME_TYPE, - * text: await fs.readFile("dist/view.html", "utf-8"), - * }, - * ], - * }), - * ); + * ```typescript + * import { registerAppResource } from '@modelcontextprotocol/ext-apps/server'; + * + * registerAppResource(server, "Weather Widget", "ui://weather/widget.html", { + * description: "Interactive weather display", + * }, async () => ({ + * contents: [{ + * uri: "ui://weather/widget.html", + * mimeType: RESOURCE_MIME_TYPE, + * text: await fs.readFile("dist/widget.html", "utf-8"), + * }], + * })); * ``` * - * @example With CSP configuration for network access - * ```ts source="./index.examples.ts#registerAppResource_withCsp" - * registerAppResource( - * server, - * "Music Player", - * "ui://music/player.html", - * { - * description: "Audio player with external soundfonts", - * }, - * async () => ({ - * contents: [ - * { - * uri: "ui://music/player.html", - * mimeType: RESOURCE_MIME_TYPE, - * text: musicPlayerHtml, - * _meta: { - * ui: { - * csp: { - * resourceDomains: ["https://cdn.example.com"], // For scripts/styles/images - * connectDomains: ["https://api.example.com"], // For fetch/WebSocket - * }, - * }, - * }, + * @example With CSP configuration for external domains + * ```typescript + * registerAppResource(server, "Music Player", "ui://music/player.html", { + * description: "Audio player with external soundfonts", + * _meta: { + * ui: { + * csp: { + * connectDomains: ["https://api.example.com"], // For fetch/WebSocket + * resourceDomains: ["https://cdn.example.com"], // For scripts/styles/images * }, - * ], - * }), - * ); + * }, + * }, + * }, readCallback); * ``` * - * @example With stable origin for external API CORS allowlists - * ```ts source="./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, - * }, - * }, - * }, - * ], - * }), - * ); - * ``` - * - * @see {@link McpUiResourceMeta `McpUiResourceMeta`} for `_meta.ui` configuration options - * @see {@link McpUiResourceCsp `McpUiResourceCsp`} for CSP domain allowlist configuration - * @see {@link registerAppTool `registerAppTool`} to register tools that reference this resource + * @see {@link registerAppTool} to register tools that reference this resource */ export function registerAppResource( server: Pick<McpServer, "registerResource">, name: string, uri: string, config: McpUiAppResourceConfig, - readCallback: McpUiReadResourceCallback, -): RegisteredResource { - return server.registerResource( + readCallback: ReadResourceCallback, +): void { + server.registerResource( name, uri, { @@ -452,61 +274,3 @@ export function registerAppResource( readCallback, ); } - -/** - * Extension identifier for MCP Apps capability negotiation. - * - * Used as the key in `extensions` to advertise MCP Apps support. - */ -export const EXTENSION_ID = "io.modelcontextprotocol/ui"; - -/** - * Get MCP Apps capability settings from client capabilities. - * - * This helper retrieves the capability object from the `extensions` field - * where MCP Apps advertises its support. - * - * @param clientCapabilities - The client capabilities from the initialize response - * @returns The MCP Apps capability settings, or `undefined` if not supported - * - * @example Check for MCP Apps support in server initialization - * ```ts source="./index.examples.ts#getUiCapability_checkSupport" - * server.server.oninitialized = () => { - * const clientCapabilities = server.server.getClientCapabilities(); - * const uiCap = getUiCapability(clientCapabilities); - * - * if (uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) { - * // App-enhanced tool - * registerAppTool( - * server, - * "weather", - * { - * description: "Get weather information with interactive dashboard", - * _meta: { ui: { resourceUri: "ui://weather/dashboard" } }, - * }, - * weatherHandler, - * ); - * } else { - * // Text-only fallback - * server.registerTool( - * "weather", - * { - * description: "Get weather information", - * }, - * textWeatherHandler, - * ); - * } - * }; - * ``` - */ -export function getUiCapability( - clientCapabilities: ClientCapabilities | null | undefined, -): McpUiClientCapabilities | undefined { - if (!clientCapabilities) { - return undefined; - } - - return clientCapabilities.extensions?.[EXTENSION_ID] as - | McpUiClientCapabilities - | undefined; -} diff --git a/src/spec.types.ts b/src/spec.types.ts index 0c45396..cb6af1f 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -7,18 +7,16 @@ * - Use `@description` JSDoc tags to generate `.describe()` calls on schemas * - Run `npm run generate:schemas` to regenerate schemas from these types * - * @see https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx + * @see https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx */ import type { CallToolResult, ContentBlock, - EmbeddedResource, Implementation, RequestId, - ResourceLink, Tool, -} from "@modelcontextprotocol/client"; +} from "@modelcontextprotocol/sdk/types.js"; /** * Current protocol version supported by this SDK. @@ -26,7 +24,7 @@ import type { * The SDK automatically handles version negotiation during initialization. * Apps and hosts don't need to manage protocol versions manually. */ -export const LATEST_PROTOCOL_VERSION = "2026-01-26"; +export const LATEST_PROTOCOL_VERSION = "2025-11-21"; /** * @description Color theme preference for the host environment. @@ -64,6 +62,7 @@ export type McpUiStyleVariableKey = | "--color-text-success" | "--color-text-warning" | "--color-text-disabled" + | "--color-text-ghost" // Border colors | "--color-border-primary" | "--color-border-secondary" @@ -145,7 +144,7 @@ export type McpUiStyles = Record<McpUiStyleVariableKey, string | undefined>; /** * @description Request to open an external URL in the host's default browser. - * @see {@link app!App.openLink `App.openLink`} for the method that sends this request + * @see {@link app!App.openLink} for the method that sends this request */ export interface McpUiOpenLinkRequest { method: "ui/open-link"; @@ -157,53 +156,21 @@ export interface McpUiOpenLinkRequest { /** * @description Result from opening a URL. - * @see {@link McpUiOpenLinkRequest `McpUiOpenLinkRequest`} + * @see {@link McpUiOpenLinkRequest} */ export interface McpUiOpenLinkResult { /** @description True if the host failed to open the URL (e.g., due to security policy). */ isError?: boolean; /** - * Additional result properties are preserved for protocol extensibility. - * The generated schema uses passthrough() to allow them. - */ - [key: string]: unknown; -} - -/** - * @description Request to download a file through the host. - * - * Sent from the View to the Host when the app wants to trigger a file download. - * Since MCP Apps run in sandboxed iframes where direct downloads are blocked, - * this provides a host-mediated mechanism for file exports. - * The host SHOULD show a confirmation dialog before initiating the download. - * - * @see {@link app!App.downloadFile `App.downloadFile`} for the method that sends this request - */ -export interface McpUiDownloadFileRequest { - method: "ui/download-file"; - params: { - /** @description Resource contents to download โ€” embedded (inline data) or linked (host fetches). Uses standard MCP resource types. */ - contents: (EmbeddedResource | ResourceLink)[]; - }; -} - -/** - * @description Result from a file download request. - * @see {@link McpUiDownloadFileRequest `McpUiDownloadFileRequest`} - */ -export interface McpUiDownloadFileResult { - /** @description True if the download failed (e.g., user cancelled or host denied). */ - isError?: boolean; - /** - * Additional result properties are preserved for protocol extensibility. - * The generated schema uses passthrough() to allow them. + * Index signature required for MCP SDK `Protocol` class compatibility. + * Note: The generated schema uses passthrough() to allow additional properties. */ [key: string]: unknown; } /** * @description Request to send a message to the host's chat interface. - * @see {@link app!App.sendMessage `App.sendMessage`} for the method that sends this request + * @see {@link app!App.sendMessage} for the method that sends this request */ export interface McpUiMessageRequest { method: "ui/message"; @@ -217,14 +184,14 @@ export interface McpUiMessageRequest { /** * @description Result from sending a message. - * @see {@link McpUiMessageRequest `McpUiMessageRequest`} + * @see {@link McpUiMessageRequest} */ export interface McpUiMessageResult { /** @description True if the host rejected or failed to deliver the message. */ isError?: boolean; /** - * Additional result properties are preserved for protocol extensibility. - * The generated schema uses passthrough() to allow them. + * Index signature required for MCP SDK `Protocol` class compatibility. + * Note: The generated schema uses passthrough() to allow additional properties. */ [key: string]: unknown; } @@ -232,7 +199,7 @@ export interface McpUiMessageResult { /** * @description Notification that the sandbox proxy iframe is ready to receive content. * @internal - * @see https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#sandbox-proxy + * @see https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#sandbox-proxy */ export interface McpUiSandboxProxyReadyNotification { method: "ui/notifications/sandbox-proxy-ready"; @@ -242,7 +209,7 @@ export interface McpUiSandboxProxyReadyNotification { /** * @description Notification containing HTML resource for the sandbox proxy to load. * @internal - * @see https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#sandbox-proxy + * @see https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#sandbox-proxy */ export interface McpUiSandboxResourceReadyNotification { method: "ui/notifications/sandbox-resource-ready"; @@ -259,8 +226,8 @@ export interface McpUiSandboxResourceReadyNotification { } /** - * @description Notification of UI size changes (View -> Host). - * @see {@link app!App.sendSizeChanged `App.sendSizeChanged`} for the method to send this from View + * @description Notification of UI size changes (Guest UI -> Host). + * @see {@link app!App.sendSizeChanged} for the method to send this from Guest UI */ export interface McpUiSizeChangedNotification { method: "ui/notifications/size-changed"; @@ -273,7 +240,7 @@ export interface McpUiSizeChangedNotification { } /** - * @description Notification containing complete tool arguments (Host -> View). + * @description Notification containing complete tool arguments (Host -> Guest UI). */ export interface McpUiToolInputNotification { method: "ui/notifications/tool-input"; @@ -284,7 +251,7 @@ export interface McpUiToolInputNotification { } /** - * @description Notification containing partial/streaming tool arguments (Host -> View). + * @description Notification containing partial/streaming tool arguments (Host -> Guest UI). */ export interface McpUiToolInputPartialNotification { method: "ui/notifications/tool-input-partial"; @@ -295,7 +262,7 @@ export interface McpUiToolInputPartialNotification { } /** - * @description Notification containing tool execution result (Host -> View). + * @description Notification containing tool execution result (Host -> Guest UI). */ export interface McpUiToolResultNotification { method: "ui/notifications/tool-result"; @@ -304,7 +271,7 @@ export interface McpUiToolResultNotification { } /** - * @description Notification that tool execution was cancelled (Host -> View). + * @description Notification that tool execution was cancelled (Host -> Guest UI). * Host MUST send this if tool execution was cancelled for any reason (user action, * sampling error, classifier intervention, etc.). */ @@ -320,7 +287,7 @@ export interface McpUiToolCancelledNotification { * @description CSS blocks that can be injected by apps. */ export interface McpUiHostCss { - /** @description CSS for font loading (`@font-face` rules or `@import` statements). Apps must apply using {@link applyHostFonts `applyHostFonts`}. */ + /** @description CSS for font loading (`@font-face` rules or `@import` statements). Apps must apply using {@link applyHostFonts}. */ fonts?: string; } @@ -335,7 +302,7 @@ export interface McpUiHostStyles { } /** - * @description Rich context about the host environment provided to views. + * @description Rich context about the host environment provided to Guest UIs. */ export interface McpUiHostContext { /** @description Allow additional properties for forward compatibility. */ @@ -354,7 +321,7 @@ export interface McpUiHostContext { /** @description How the UI is currently displayed. */ displayMode?: McpUiDisplayMode; /** @description Display modes the host supports. */ - availableDisplayModes?: McpUiDisplayMode[]; + availableDisplayModes?: string[]; /** * @description Container dimensions. Represents the dimensions of the iframe or other * container holding the app. Specify either width or maxWidth, and either height or maxHeight. @@ -408,8 +375,8 @@ export interface McpUiHostContext { } /** - * @description Notification that host context has changed (Host -> View). - * @see {@link McpUiHostContext `McpUiHostContext`} for the full context structure + * @description Notification that host context has changed (Host -> Guest UI). + * @see {@link McpUiHostContext} for the full context structure */ export interface McpUiHostContextChangedNotification { method: "ui/notifications/host-context-changed"; @@ -418,16 +385,16 @@ export interface McpUiHostContextChangedNotification { } /** - * @description Request to update the agent's context without requiring a follow-up action (View -> Host). + * @description Request to update the agent's context without requiring a follow-up action (Guest UI -> Host). * * Unlike `notifications/message` which is for debugging/logging, this request is intended - * to update the Host's model context. Each request overwrites the previous context sent by the View. + * to update the Host's model context. Each request overwrites the previous context sent by the Guest UI. * Unlike messages, context updates do not trigger follow-ups. * * The host will typically defer sending the context to the model until the next user message * (including `ui/message`), and will only send the last update received. * - * @see {@link app.App.updateModelContext `App.updateModelContext`} for the method that sends this request + * @see {@link app.App.updateModelContext} for the method that sends this request */ export interface McpUiUpdateModelContextRequest { method: "ui/update-model-context"; @@ -440,8 +407,8 @@ export interface McpUiUpdateModelContextRequest { } /** - * @description Request for graceful shutdown of the View (Host -> View). - * @see {@link app-bridge!AppBridge.teardownResource `AppBridge.teardownResource`} for the host method that sends this + * @description Request for graceful shutdown of the Guest UI (Host -> Guest UI). + * @see {@link app-bridge!AppBridge.teardownResource} for the host method that sends this */ export interface McpUiResourceTeardownRequest { method: "ui/resource-teardown"; @@ -450,11 +417,11 @@ export interface McpUiResourceTeardownRequest { /** * @description Result from graceful shutdown request. - * @see {@link McpUiResourceTeardownRequest `McpUiResourceTeardownRequest`} + * @see {@link McpUiResourceTeardownRequest} */ export interface McpUiResourceTeardownResult { /** - * Additional result properties are preserved for protocol extensibility. + * Index signature required for MCP SDK `Protocol` class compatibility. */ [key: string]: unknown; } @@ -474,30 +441,15 @@ export interface McpUiSupportedContentBlockModalities { structuredContent?: {}; } -/** - * @description Notification for app-initiated teardown request (View -> Host). - * Views send this to request that the host tear them down. The host decides - * whether to proceed - if approved, the host will send - * `ui/resource-teardown` to allow the view to perform cleanup before being - * unmounted. - * @see {@link app.App.requestTeardown} for the app method that sends this - */ -export interface McpUiRequestTeardownNotification { - method: "ui/notifications/request-teardown"; - params?: {}; -} - /** * @description Capabilities supported by the host application. - * @see {@link McpUiInitializeResult `McpUiInitializeResult`} for the initialization result that includes these capabilities + * @see {@link McpUiInitializeResult} for the initialization result that includes these capabilities */ export interface McpUiHostCapabilities { - /** @description Experimental features keyed by identifier. */ - experimental?: Record<string, object>; + /** @description Experimental features (structure TBD). */ + experimental?: {}; /** @description Host supports opening external URLs. */ openLinks?: {}; - /** @description Host supports file downloads via ui/download-file. */ - downloadFile?: {}; /** @description Host can proxy tool calls to the MCP server. */ serverTools?: { /** @description Host supports tools/list_changed notifications. */ @@ -519,37 +471,27 @@ export interface McpUiHostCapabilities { }; /** @description Host accepts context updates (ui/update-model-context) to be included in the model's context for future turns. */ updateModelContext?: McpUiSupportedContentBlockModalities; - /** @description Host supports receiving content messages (ui/message) from the view. */ + /** @description Host supports receiving content messages (ui/message) from the Guest UI. */ message?: McpUiSupportedContentBlockModalities; - /** - * @description Host supports LLM sampling (sampling/createMessage) from the view. - * Mirrors the MCP `ClientCapabilities.sampling` shape so hosts can pass it through. - */ - sampling?: { - /** @description Host supports tool use via `tools` and `toolChoice` parameters. */ - tools?: {}; - }; } /** - * @description Capabilities provided by the View ({@link app!App `App`}). - * @see {@link McpUiInitializeRequest `McpUiInitializeRequest`} for the initialization request that includes these capabilities + * @description Capabilities provided by the Guest UI ({@link app!App}). + * @see {@link McpUiInitializeRequest} for the initialization request that includes these capabilities */ export interface McpUiAppCapabilities { - /** @description Experimental features keyed by identifier. */ - experimental?: Record<string, object>; + /** @description Experimental features (structure TBD). */ + experimental?: {}; /** @description App exposes MCP-style tools that the host can call. */ tools?: { /** @description App supports tools/list_changed notifications. */ listChanged?: boolean; }; - /** @description Display modes the app supports. */ - availableDisplayModes?: McpUiDisplayMode[]; } /** - * @description Initialization request sent from View to Host. - * @see {@link app!App.connect `App.connect`} for the method that sends this request + * @description Initialization request sent from Guest UI to Host. + * @see {@link app!App.connect} for the method that sends this request */ export interface McpUiInitializeRequest { method: "ui/initialize"; @@ -564,8 +506,8 @@ export interface McpUiInitializeRequest { } /** - * @description Initialization result returned from Host to View. - * @see {@link McpUiInitializeRequest `McpUiInitializeRequest`} + * @description Initialization result returned from Host to Guest UI. + * @see {@link McpUiInitializeRequest} */ export interface McpUiInitializeResult { /** @description Negotiated protocol version string (e.g., "2025-11-21"). */ @@ -577,15 +519,15 @@ export interface McpUiInitializeResult { /** @description Rich context about the host environment. */ hostContext: McpUiHostContext; /** - * Additional result properties are preserved for protocol extensibility. - * The generated schema uses passthrough() to allow them. + * Index signature required for MCP SDK `Protocol` class compatibility. + * Note: The generated schema uses passthrough() to allow additional properties. */ [key: string]: unknown; } /** - * @description Notification that View has completed initialization (View -> Host). - * @see {@link app!App.connect `App.connect`} for the method that sends this notification + * @description Notification that Guest UI has completed initialization (Guest UI -> Host). + * @see {@link app!App.connect} for the method that sends this notification */ export interface McpUiInitializedNotification { method: "ui/notifications/initialized"; @@ -594,97 +536,31 @@ export interface McpUiInitializedNotification { /** * @description Content Security Policy configuration for UI resources. - * - * Servers declare which origins their UI requires. Hosts use this to enforce appropriate CSP headers. - * - * > [!IMPORTANT] - * > MCP App HTML runs in a sandboxed iframe with no same-origin server. - * > **All** origins must be declaredโ€”including where your bundled JS/CSS is - * > served from (`localhost` in dev, your CDN in production). */ export interface McpUiResourceCsp { - /** - * @description Origins for network requests (fetch/XHR/WebSocket). - * - * - Maps to CSP `connect-src` directive - * - Empty or omitted โ†’ no network connections (secure default) - * - * @example - * ```ts - * ["https://api.weather.com", "wss://realtime.service.com"] - * ``` - */ + /** @description Origins for network requests (fetch/XHR/WebSocket). */ connectDomains?: string[]; - /** - * @description Origins for static resources (images, scripts, stylesheets, fonts, media). - * - * - Maps to CSP `img-src`, `script-src`, `style-src`, `font-src`, `media-src` directives - * - Wildcard subdomains supported: `https://*.example.com` - * - Empty or omitted โ†’ no network resources (secure default) - * - * @example - * ```ts - * ["https://cdn.jsdelivr.net", "https://*.cloudflare.com"] - * ``` - */ + /** @description Origins for static resources (scripts, images, styles, fonts). */ resourceDomains?: string[]; - /** - * @description Origins for nested iframes. - * - * - Maps to CSP `frame-src` directive - * - Empty or omitted โ†’ no nested iframes allowed (`frame-src 'none'`) - * - * @example - * ```ts - * ["https://www.youtube.com", "https://player.vimeo.com"] - * ``` - */ + /** @description Origins for nested iframes (frame-src directive). */ frameDomains?: string[]; - /** - * @description Allowed base URIs for the document. - * - * - Maps to CSP `base-uri` directive - * - Empty or omitted โ†’ only same origin allowed (`base-uri 'self'`) - * - * @example - * ```ts - * ["https://cdn.example.com"] - * ``` - */ + /** @description Allowed base URIs for the document (base-uri directive). */ baseUriDomains?: string[]; } /** * @description Sandbox permissions requested by the UI resource. - * - * Servers declare which browser capabilities their UI needs. * Hosts MAY honor these by setting appropriate iframe `allow` attributes. * Apps SHOULD NOT assume permissions are granted; use JS feature detection as fallback. */ export interface McpUiResourcePermissions { - /** - * @description Request camera access. - * - * Maps to Permission Policy `camera` feature. - */ + /** @description Request camera access (Permission Policy `camera` feature). */ camera?: {}; - /** - * @description Request microphone access. - * - * Maps to Permission Policy `microphone` feature. - */ + /** @description Request microphone access (Permission Policy `microphone` feature). */ microphone?: {}; - /** - * @description Request geolocation access. - * - * Maps to Permission Policy `geolocation` feature. - */ + /** @description Request geolocation access (Permission Policy `geolocation` feature). */ geolocation?: {}; - /** - * @description Request clipboard write access. - * - * Maps to Permission Policy `clipboard-write` feature. - */ + /** @description Request clipboard write access (Permission Policy `clipboard-write` feature). */ clipboardWrite?: {}; } @@ -692,41 +568,13 @@ export interface McpUiResourcePermissions { * @description UI Resource metadata for security and rendering configuration. */ export interface McpUiResourceMeta { - /** @description Content Security Policy configuration for UI resources. */ + /** @description Content Security Policy configuration. */ csp?: McpUiResourceCsp; - /** @description Sandbox permissions requested by the UI resource. */ + /** @description Sandbox permissions requested by the UI. */ permissions?: McpUiResourcePermissions; - /** - * @description Dedicated origin for view sandbox. - * - * Useful when views need stable, dedicated origins for OAuth callbacks, CORS policies, or API key allowlists. - * - * **Host-dependent:** The format and validation rules for this field are determined by each host. Servers MUST consult host-specific documentation for the expected domain format. Common patterns include: - * - Hash-based subdomains (e.g., `{hash}.claudemcpcontent.com`) - * - URL-derived subdomains (e.g., `www-example-com.oaiusercontent.com`) - * - * If omitted, host uses default sandbox origin (typically per-conversation). - * - * @example - * ```ts - * "a904794854a047f6.claudemcpcontent.com" - * ``` - * - * @example - * ```ts - * "www-example-com.oaiusercontent.com" - * ``` - */ + /** @description Dedicated origin for widget sandbox. */ domain?: string; - /** - * @description Visual boundary preference - true if view prefers a visible border. - * - * Boolean requesting whether a visible border and background is provided by the host. Specifying an explicit value for this is recommended because hosts' defaults may vary. - * - * - `true`: request visible border + background - * - `false`: request no visible border + background - * - omitted: host decides border - */ + /** @description Visual boundary preference - true if UI prefers a visible border. */ prefersBorder?: boolean; } @@ -734,7 +582,7 @@ export interface McpUiResourceMeta { * @description Request to change the display mode of the UI. * The host will respond with the actual display mode that was set, * which may differ from the requested mode if not supported. - * @see {@link app!App.requestDisplayMode `App.requestDisplayMode`} for the method that sends this request + * @see {@link app!App.requestDisplayMode} for the method that sends this request */ export interface McpUiRequestDisplayModeRequest { method: "ui/request-display-mode"; @@ -746,13 +594,13 @@ export interface McpUiRequestDisplayModeRequest { /** * @description Result from requesting a display mode change. - * @see {@link McpUiRequestDisplayModeRequest `McpUiRequestDisplayModeRequest`} + * @see {@link McpUiRequestDisplayModeRequest} */ export interface McpUiRequestDisplayModeResult { /** @description The display mode that was actually set. May differ from requested if not supported. */ mode: McpUiDisplayMode; /** - * Additional result properties are preserved for protocol extensibility. + * Index signature required for MCP SDK `Protocol` class compatibility. * Note: The generated schema uses passthrough() to allow additional properties. */ [key: string]: unknown; @@ -769,11 +617,9 @@ export type McpUiToolVisibility = "model" | "app"; export interface McpUiToolMeta { /** * URI of the UI resource to display for this tool, if any. + * This is converted to `_meta["ui/resourceUri"]`. * - * @example - * ```ts - * "ui://weather/view.html" - * ``` + * @example "ui://weather/widget.html" */ resourceUri?: string; /** @@ -782,17 +628,6 @@ export interface McpUiToolMeta { * - "app": Tool callable by the app from this server only */ visibility?: McpUiToolVisibility[]; - /** - * `csp` belongs on the UI **resource** (see {@link McpUiResourceMeta}), - * not the tool. Hosts read it from the `resources/read` content item - * (with `resources/list` entry as fallback) and ignore it here. - */ - csp?: never; - /** - * `permissions` belongs on the UI **resource** (see {@link McpUiResourceMeta}), - * not the tool. Hosts ignore it here. - */ - permissions?: never; } /** @@ -812,8 +647,6 @@ export interface McpUiToolMeta { * ``` */ export const OPEN_LINK_METHOD: McpUiOpenLinkRequest["method"] = "ui/open-link"; -export const DOWNLOAD_FILE_METHOD: McpUiDownloadFileRequest["method"] = - "ui/download-file"; export const MESSAGE_METHOD: McpUiMessageRequest["method"] = "ui/message"; export const SANDBOX_PROXY_READY_METHOD: McpUiSandboxProxyReadyNotification["method"] = "ui/notifications/sandbox-proxy-ready"; @@ -831,8 +664,6 @@ export const TOOL_CANCELLED_METHOD: McpUiToolCancelledNotification["method"] = "ui/notifications/tool-cancelled"; export const HOST_CONTEXT_CHANGED_METHOD: McpUiHostContextChangedNotification["method"] = "ui/notifications/host-context-changed"; -export const REQUEST_TEARDOWN_METHOD: McpUiRequestTeardownNotification["method"] = - "ui/notifications/request-teardown"; export const RESOURCE_TEARDOWN_METHOD: McpUiResourceTeardownRequest["method"] = "ui/resource-teardown"; export const INITIALIZE_METHOD: McpUiInitializeRequest["method"] = @@ -841,18 +672,3 @@ export const INITIALIZED_METHOD: McpUiInitializedNotification["method"] = "ui/notifications/initialized"; export const REQUEST_DISPLAY_MODE_METHOD: McpUiRequestDisplayModeRequest["method"] = "ui/request-display-mode"; - -/** - * @description MCP Apps capability settings advertised by clients to servers. - * - * Clients advertise these capabilities via the `extensions` field in their - * capabilities during MCP initialization. Servers can check for MCP Apps - * support using {@link server-helpers!getUiCapability}. - */ -export interface McpUiClientCapabilities { - /** - * @description Array of supported MIME types for UI resources. - * Must include `"text/html;profile=mcp-app"` for MCP Apps support. - */ - mimeTypes?: string[]; -} diff --git a/src/standard-schema.ts b/src/standard-schema.ts deleted file mode 100644 index a06864a..0000000 --- a/src/standard-schema.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { - StandardJSONSchemaV1, - StandardSchemaV1, - StandardTypedV1, -} from "@standard-schema/spec"; - -import type { StandardSchemaWithJSON } from "@modelcontextprotocol/client"; - -/** - * A schema that implements both Standard Schema (validation) and Standard JSON - * Schema (serialization). Zod v4, ArkType, and Valibot (via - * `@valibot/to-json-schema`) all satisfy this. Re-exported from the SDK so - * View authors can import it alongside {@link app!App `App`}. - * - * @see https://standardschema.dev/ - */ -export type { - StandardJSONSchemaV1, - StandardSchemaV1, - StandardSchemaWithJSON, - StandardTypedV1, -}; - -/** JSON-Schema target draft used for tool input/output schemas (matches core MCP). */ -const TARGET = { target: "draft-2020-12" } as const; - -/** - * Serialize a Standard Schema to JSON Schema for the given direction. - * - * Requires `~standard.jsonSchema` (zod v4, ArkType, Valibot, โ€ฆ); schemas - * without it throw. - */ -export async function standardSchemaToJsonSchema( - schema: StandardSchemaV1, - io: "input" | "output", -): Promise<Record<string, unknown>> { - const std = schema["~standard"] as Partial< - StandardSchemaWithJSON["~standard"] - >; - if (std.jsonSchema) { - return std.jsonSchema[io](TARGET); - } - throw new Error( - `Schema (vendor: ${std.vendor}) does not implement Standard JSON Schema (~standard.jsonSchema). ` + - `Use a library that does (zod v4, ArkType, Valibot) or wrap your schema accordingly.`, - ); -} - -/** - * Validate a value against a Standard Schema. Returns the parsed value on - * success or throws with a formatted issue list (optionally prefixed). - */ -export async function validateStandardSchema<S extends StandardSchemaV1>( - schema: S, - value: unknown, - errorPrefix = "", -): Promise<StandardSchemaV1.InferOutput<S>> { - const result = await schema["~standard"].validate(value); - if (result.issues) { - const msg = result.issues - .map((i) => { - const path = i.path - ?.map((p) => (typeof p === "object" ? p.key : p)) - .join("."); - return path ? `${path}: ${i.message}` : i.message; - }) - .join("; "); - throw new Error(errorPrefix + msg); - } - return result.value; -} diff --git a/src/styles.examples.ts b/src/styles.examples.ts deleted file mode 100644 index 8f367e6..0000000 --- a/src/styles.examples.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Type-checked examples for style utilities in {@link ./styles.ts `styles.ts`}. - * - * These examples are included in the API documentation via `@includeCode` tags. - * Each function's region markers define the code snippet that appears in the docs. - * - * @module - */ - -import { App } from "./app.js"; -import { - getDocumentTheme, - applyDocumentTheme, - applyHostStyleVariables, - applyHostFonts, -} from "./styles.js"; - -/** - * Example: Check current theme. - */ -function getDocumentTheme_checkCurrent() { - //#region getDocumentTheme_checkCurrent - const theme = getDocumentTheme(); - document.body.classList.toggle("dark", theme === "dark"); - //#endregion getDocumentTheme_checkCurrent -} - -/** - * Example: Apply theme from host context. - */ -function applyDocumentTheme_fromHostContext(app: App) { - //#region applyDocumentTheme_fromHostContext - // Apply when host context changes - app.onhostcontextchanged = (ctx) => { - if (ctx.theme) { - applyDocumentTheme(ctx.theme); - } - }; - - // Apply initial theme after connecting - app.connect().then(() => { - const ctx = app.getHostContext(); - if (ctx?.theme) { - applyDocumentTheme(ctx.theme); - } - }); - //#endregion applyDocumentTheme_fromHostContext -} - -/** - * Example: Apply style variables from host context. - */ -function applyHostStyleVariables_fromHostContext(app: App) { - //#region applyHostStyleVariables_fromHostContext - // Use CSS variables in your styles - document.body.style.background = "var(--color-background-primary)"; - - // Apply when host context changes - app.onhostcontextchanged = (ctx) => { - if (ctx.styles?.variables) { - applyHostStyleVariables(ctx.styles.variables); - } - }; - - // Apply initial styles after connecting - app.connect().then(() => { - const ctx = app.getHostContext(); - if (ctx?.styles?.variables) { - applyHostStyleVariables(ctx.styles.variables); - } - }); - //#endregion applyHostStyleVariables_fromHostContext -} - -/** - * Example: Apply to a specific element. - */ -function applyHostStyleVariables_toElement(app: App) { - //#region applyHostStyleVariables_toElement - app.onhostcontextchanged = (ctx) => { - const container = document.getElementById("app-root"); - if (container && ctx.styles?.variables) { - applyHostStyleVariables(ctx.styles.variables, container); - } - }; - //#endregion applyHostStyleVariables_toElement -} - -/** - * Example: Apply fonts from host context. - */ -function applyHostFonts_fromHostContext(app: App) { - //#region applyHostFonts_fromHostContext - // Apply when host context changes - app.onhostcontextchanged = (ctx) => { - if (ctx.styles?.css?.fonts) { - applyHostFonts(ctx.styles.css.fonts); - } - }; - - // Apply initial fonts after connecting - app.connect().then(() => { - const ctx = app.getHostContext(); - if (ctx?.styles?.css?.fonts) { - applyHostFonts(ctx.styles.css.fonts); - } - }); - //#endregion applyHostFonts_fromHostContext -} - -/** - * Example: Host providing self-hosted fonts. - */ -function applyHostFonts_selfHosted() { - //#region applyHostFonts_selfHosted - // Example of what a host might provide: - const fontCss = ` - @font-face { - font-family: "Anthropic Sans"; - src: url("https://assets.anthropic.com/.../Regular.otf") format("opentype"); - font-weight: 400; - } - `; - applyHostFonts(fontCss); - //#endregion applyHostFonts_selfHosted -} - -/** - * Example: Host providing Google Fonts. - */ -function applyHostFonts_googleFonts() { - //#region applyHostFonts_googleFonts - // Example of what a host might provide: - const fontCss = ` - @import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap'); - `; - applyHostFonts(fontCss); - //#endregion applyHostFonts_googleFonts -} diff --git a/src/styles.ts b/src/styles.ts index 895073b..322c5e9 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -1,4 +1,4 @@ -import { McpUiStyles, McpUiTheme } from "./types.js"; +import { McpUiStyles, McpUiTheme } from "./types"; /** * Get the current document theme from the root HTML element. @@ -10,13 +10,15 @@ import { McpUiStyles, McpUiTheme } from "./types.js"; * @returns The current theme ("light" or "dark") * * @example Check current theme - * ```ts source="./styles.examples.ts#getDocumentTheme_checkCurrent" + * ```typescript + * import { getDocumentTheme } from '@modelcontextprotocol/ext-apps'; + * * const theme = getDocumentTheme(); - * document.body.classList.toggle("dark", theme === "dark"); + * console.log(`Current theme: ${theme}`); * ``` * - * @see {@link applyDocumentTheme `applyDocumentTheme`} to set the theme - * @see {@link McpUiTheme `McpUiTheme`} for the theme type + * @see {@link applyDocumentTheme} to set the theme + * @see {@link McpUiTheme} for the theme type */ export function getDocumentTheme(): McpUiTheme { const theme = document.documentElement.getAttribute("data-theme"); @@ -42,21 +44,14 @@ export function getDocumentTheme(): McpUiTheme { * @param theme - The theme to apply ("light" or "dark") * * @example Apply theme from host context - * ```ts source="./styles.examples.ts#applyDocumentTheme_fromHostContext" - * // Apply when host context changes - * app.onhostcontextchanged = (ctx) => { - * if (ctx.theme) { - * applyDocumentTheme(ctx.theme); + * ```typescript + * import { applyDocumentTheme } from '@modelcontextprotocol/ext-apps'; + * + * app.onhostcontextchanged = (params) => { + * if (params.theme) { + * applyDocumentTheme(params.theme); * } * }; - * - * // Apply initial theme after connecting - * app.connect().then(() => { - * const ctx = app.getHostContext(); - * if (ctx?.theme) { - * applyDocumentTheme(ctx.theme); - * } - * }); * ``` * * @example Use with CSS selectors @@ -69,8 +64,8 @@ export function getDocumentTheme(): McpUiTheme { * } * ``` * - * @see {@link getDocumentTheme `getDocumentTheme`} to read the current theme - * @see {@link McpUiTheme `McpUiTheme`} for the theme type + * @see {@link getDocumentTheme} to read the current theme + * @see {@link McpUiTheme} for the theme type */ export function applyDocumentTheme(theme: McpUiTheme): void { const root = document.documentElement; @@ -81,7 +76,7 @@ export function applyDocumentTheme(theme: McpUiTheme): void { /** * Apply host style variables as CSS custom properties on an element. * - * This function takes the `variables` object from {@link McpUiHostContext.styles `McpUiHostContext.styles`} and sets + * This function takes the `variables` object from {@link McpUiHostContext.styles} and sets * each CSS variable on the specified root element (defaults to `document.documentElement`). * This allows apps to use the host's theming values via CSS variables like * `var(--color-background-primary)`. @@ -90,51 +85,24 @@ export function applyDocumentTheme(theme: McpUiTheme): void { * @param root - The element to apply styles to (defaults to `document.documentElement`) * * @example Apply style variables from host context - * ```ts source="./styles.examples.ts#applyHostStyleVariables_fromHostContext" - * // Use CSS variables in your styles - * document.body.style.background = "var(--color-background-primary)"; + * ```typescript + * import { applyHostStyleVariables } from '@modelcontextprotocol/ext-apps'; * - * // Apply when host context changes - * app.onhostcontextchanged = (ctx) => { - * if (ctx.styles?.variables) { - * applyHostStyleVariables(ctx.styles.variables); + * app.onhostcontextchanged = (params) => { + * if (params.styles?.variables) { + * applyHostStyleVariables(params.styles.variables); * } * }; - * - * // Apply initial styles after connecting - * app.connect().then(() => { - * const ctx = app.getHostContext(); - * if (ctx?.styles?.variables) { - * applyHostStyleVariables(ctx.styles.variables); - * } - * }); * ``` * * @example Apply to a specific element - * ```ts source="./styles.examples.ts#applyHostStyleVariables_toElement" - * app.onhostcontextchanged = (ctx) => { - * const container = document.getElementById("app-root"); - * if (container && ctx.styles?.variables) { - * applyHostStyleVariables(ctx.styles.variables, container); - * } - * }; + * ```typescript + * const container = document.getElementById('app-root'); + * applyHostStyleVariables(hostContext.styles?.variables, container); * ``` * - * @example Use host style variables in CSS - * ```css - * body { - * background-color: var(--color-background-primary); - * color: var(--color-text-primary); - * } - * - * .card { - * background-color: var(--color-background-secondary); - * border: 1px solid var(--color-border-primary); - * } - * ``` - * - * @see {@link McpUiStyles `McpUiStyles`} for the available CSS variables - * @see {@link McpUiHostContext `McpUiHostContext`} for the full host context structure + * @see {@link McpUiStyles} for the available CSS variables + * @see {@link McpUiHostContext} for the full host context structure */ export function applyHostStyleVariables( styles: McpUiStyles, @@ -161,53 +129,42 @@ export function applyHostStyleVariables( * @param fontCss - CSS string containing `@font-face` rules and/or `@import` statements * * @example Apply fonts from host context - * ```ts source="./styles.examples.ts#applyHostFonts_fromHostContext" - * // Apply when host context changes - * app.onhostcontextchanged = (ctx) => { - * if (ctx.styles?.css?.fonts) { - * applyHostFonts(ctx.styles.css.fonts); + * ```typescript + * import { applyHostFonts } from '@modelcontextprotocol/ext-apps'; + * + * app.onhostcontextchanged = (params) => { + * if (params.styles?.css?.fonts) { + * applyHostFonts(params.styles.css.fonts); * } * }; - * - * // Apply initial fonts after connecting - * app.connect().then(() => { - * const ctx = app.getHostContext(); - * if (ctx?.styles?.css?.fonts) { - * applyHostFonts(ctx.styles.css.fonts); - * } - * }); * ``` * * @example Host providing self-hosted fonts - * ```ts source="./styles.examples.ts#applyHostFonts_selfHosted" - * // Example of what a host might provide: - * const fontCss = ` + * ```typescript + * hostContext.styles.css.fonts = ` * @font-face { * font-family: "Anthropic Sans"; * src: url("https://assets.anthropic.com/.../Regular.otf") format("opentype"); * font-weight: 400; * } * `; - * applyHostFonts(fontCss); * ``` * * @example Host providing Google Fonts - * ```ts source="./styles.examples.ts#applyHostFonts_googleFonts" - * // Example of what a host might provide: - * const fontCss = ` + * ```typescript + * hostContext.styles.css.fonts = ` * @import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap'); * `; - * applyHostFonts(fontCss); * ``` * * @example Use host fonts in CSS * ```css * body { - * font-family: var(--font-sans, system-ui, sans-serif); + * font-family: "Anthropic Sans", sans-serif; * } * ``` * - * @see {@link McpUiHostContext `McpUiHostContext`} for the full host context structure + * @see {@link McpUiHostContext} for the full host context structure */ export function applyHostFonts(fontCss: string): void { const styleId = "__mcp-host-fonts"; diff --git a/src/types.ts b/src/types.ts index e4ed629..77563dc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,60 +9,10 @@ * @see `generated/schema.test.ts` for compile-time verification */ -import type { - CallToolRequest, - CallToolResult, - CreateMessageRequest, - CreateMessageResult, - CreateMessageResultWithTools, - EmptyResult, - ListPromptsRequest, - ListPromptsResult, - ListResourceTemplatesRequest, - ListResourceTemplatesResult, - ListResourcesRequest, - ListResourcesResult, - ListToolsRequest, - ListToolsResult, - LoggingMessageNotification, - PingRequest, - PromptListChangedNotification, - ReadResourceRequest, - ReadResourceResult, - ResourceListChangedNotification, - ToolListChangedNotification, -} from "@modelcontextprotocol/client"; -import type { - McpUiDownloadFileRequest, - McpUiDownloadFileResult, - McpUiHostContextChangedNotification, - McpUiInitializeRequest, - McpUiInitializeResult, - McpUiInitializedNotification, - McpUiMessageRequest, - McpUiMessageResult, - McpUiOpenLinkRequest, - McpUiOpenLinkResult, - McpUiRequestDisplayModeRequest, - McpUiRequestDisplayModeResult, - McpUiRequestTeardownNotification, - McpUiResourceTeardownRequest, - McpUiResourceTeardownResult, - McpUiSandboxProxyReadyNotification, - McpUiSandboxResourceReadyNotification, - McpUiSizeChangedNotification, - McpUiToolCancelledNotification, - McpUiToolInputNotification, - McpUiToolInputPartialNotification, - McpUiToolResultNotification, - McpUiUpdateModelContextRequest, -} from "./spec.types.js"; - // Re-export all types from spec.types.ts export { LATEST_PROTOCOL_VERSION, OPEN_LINK_METHOD, - DOWNLOAD_FILE_METHOD, MESSAGE_METHOD, SANDBOX_PROXY_READY_METHOD, SANDBOX_RESOURCE_READY_METHOD, @@ -72,7 +22,6 @@ export { TOOL_RESULT_METHOD, TOOL_CANCELLED_METHOD, HOST_CONTEXT_CHANGED_METHOD, - REQUEST_TEARDOWN_METHOD, RESOURCE_TEARDOWN_METHOD, INITIALIZE_METHOD, INITIALIZED_METHOD, @@ -85,8 +34,6 @@ export { type McpUiHostStyles, type McpUiOpenLinkRequest, type McpUiOpenLinkResult, - type McpUiDownloadFileRequest, - type McpUiDownloadFileResult, type McpUiMessageRequest, type McpUiMessageResult, type McpUiUpdateModelContextRequest, @@ -102,7 +49,6 @@ export { type McpUiHostContextChangedNotification, type McpUiResourceTeardownRequest, type McpUiResourceTeardownResult, - type McpUiRequestTeardownNotification, type McpUiHostCapabilities, type McpUiAppCapabilities, type McpUiInitializeRequest, @@ -115,7 +61,30 @@ export { type McpUiRequestDisplayModeResult, type McpUiToolVisibility, type McpUiToolMeta, - type McpUiClientCapabilities, +} from "./spec.types.js"; + +// Import types needed for protocol type unions (not re-exported, just used internally) +import type { + McpUiInitializeRequest, + McpUiOpenLinkRequest, + McpUiMessageRequest, + McpUiUpdateModelContextRequest, + McpUiResourceTeardownRequest, + McpUiRequestDisplayModeRequest, + McpUiHostContextChangedNotification, + McpUiToolInputNotification, + McpUiToolInputPartialNotification, + McpUiToolResultNotification, + McpUiToolCancelledNotification, + McpUiSandboxResourceReadyNotification, + McpUiInitializedNotification, + McpUiSizeChangedNotification, + McpUiSandboxProxyReadyNotification, + McpUiInitializeResult, + McpUiOpenLinkResult, + McpUiMessageResult, + McpUiResourceTeardownResult, + McpUiRequestDisplayModeResult, } from "./spec.types.js"; // Re-export all schemas from generated/schema.ts (already PascalCase) @@ -126,8 +95,6 @@ export { McpUiHostStylesSchema, McpUiOpenLinkRequestSchema, McpUiOpenLinkResultSchema, - McpUiDownloadFileRequestSchema, - McpUiDownloadFileResultSchema, McpUiMessageRequestSchema, McpUiMessageResultSchema, McpUiUpdateModelContextRequestSchema, @@ -143,7 +110,6 @@ export { McpUiHostContextChangedNotificationSchema, McpUiResourceTeardownRequestSchema, McpUiResourceTeardownResultSchema, - McpUiRequestTeardownNotificationSchema, McpUiHostCapabilitiesSchema, McpUiAppCapabilitiesSchema, McpUiInitializeRequestSchema, @@ -158,16 +124,40 @@ export { McpUiToolMetaSchema, } from "./generated/schema.js"; +// Re-export SDK types used in protocol type unions +import { + CallToolRequest, + CallToolResult, + EmptyResult, + ListPromptsRequest, + ListPromptsResult, + ListResourcesRequest, + ListResourcesResult, + ListResourceTemplatesRequest, + ListResourceTemplatesResult, + ListToolsRequest, + ListToolsResult, + LoggingMessageNotification, + PingRequest, + PromptListChangedNotification, + ReadResourceRequest, + ReadResourceResult, + ResourceListChangedNotification, + ToolListChangedNotification, +} from "@modelcontextprotocol/sdk/types.js"; + /** - * Union of every request an {@link app!App `App`} may send or receive. + * All request types in the MCP Apps protocol. * - * @deprecated 1.x fed this to the SDK's `Protocol` generics; SDK 2.x derives - * request types from the method name, so nothing consumes it anymore. + * Includes: + * - MCP UI requests (initialize, open-link, message, resource-teardown, request-display-mode) + * - MCP server requests forwarded from the app (tools/call, tools/list, resources/list, + * resources/templates/list, resources/read, prompts/list) + * - Protocol requests (ping) */ export type AppRequest = | McpUiInitializeRequest | McpUiOpenLinkRequest - | McpUiDownloadFileRequest | McpUiMessageRequest | McpUiUpdateModelContextRequest | McpUiResourceTeardownRequest @@ -178,13 +168,20 @@ export type AppRequest = | ListResourceTemplatesRequest | ReadResourceRequest | ListPromptsRequest - | CreateMessageRequest | PingRequest; /** - * Union of every notification an {@link app!App `App`} may send or receive. + * All notification types in the MCP Apps protocol. * - * @deprecated See {@link AppRequest}. + * Host to app: + * - Tool lifecycle (input, input-partial, result, cancelled) + * - Host context changes + * - MCP list changes (tools, resources, prompts) + * - Sandbox resource ready + * + * App to host: + * - Initialized, size-changed, sandbox-proxy-ready + * - Logging messages */ export type AppNotification = // Sent to app @@ -201,18 +198,14 @@ export type AppNotification = | McpUiInitializedNotification | McpUiSizeChangedNotification | McpUiSandboxProxyReadyNotification - | McpUiRequestTeardownNotification | LoggingMessageNotification; /** - * Union of every result an {@link app!App `App`} may send or receive. - * - * @deprecated See {@link AppRequest}. + * All result types in the MCP Apps protocol. */ export type AppResult = | McpUiInitializeResult | McpUiOpenLinkResult - | McpUiDownloadFileResult | McpUiMessageResult | McpUiResourceTeardownResult | McpUiRequestDisplayModeResult @@ -222,6 +215,4 @@ export type AppResult = | ListResourceTemplatesResult | ReadResourceResult | ListPromptsResult - | CreateMessageResult - | CreateMessageResultWithTools | EmptyResult; diff --git a/src/wire-compat.test.ts b/src/wire-compat.test.ts deleted file mode 100644 index 762ac77..0000000 --- a/src/wire-compat.test.ts +++ /dev/null @@ -1,576 +0,0 @@ -/** - * Cross-version wire compatibility. - * - * Replays JSON-RPC messages captured from ext-apps 1.7.x (built on - * `@modelcontextprotocol/sdk` 1.x) through the 2.x `App` and `AppBridge` over - * a raw in-memory transport, and asserts what 2.x emits back. The MCP Apps - * wire protocol is meant to be unchanged across the major bump; the few - * host-side error deltas are pinned here so they stay deliberate. - */ -import { describe, it, expect, afterEach } from "bun:test"; -import { - Client, - InMemoryTransport, - ProtocolError, - type JSONRPCMessage, - type Transport, -} from "@modelcontextprotocol/client"; -import { McpServer } from "@modelcontextprotocol/server"; -import { z } from "zod/v4"; - -import { App } from "./app.js"; -import { - AppBridge, - type McpUiHostCapabilities, - type McpUiHostContext, -} from "./app-bridge.js"; -import { LATEST_PROTOCOL_VERSION } from "./types.js"; - -/** Wait for pending microtasks/timers to complete. */ -const flush = () => new Promise((resolve) => setTimeout(resolve, 5)); - -/** - * One end of a raw in-memory channel. The 2.x side gets `transport`; the test - * plays the role of the 1.x peer by calling `inject()` with captured JSON and - * reading `sent` to see exactly what 2.x put on the wire. - */ -function createRawChannel() { - const sent: JSONRPCMessage[] = []; - let closed = false; - const transport: Transport = { - async start() {}, - async send(message) { - // Serialize like a real transport would so we assert on plain JSON. - sent.push(JSON.parse(JSON.stringify(message))); - }, - async close() { - if (closed) return; - closed = true; - transport.onclose?.(); - }, - }; - return { - transport, - sent, - /** Deliver a raw message (as the 1.x peer would have sent it). */ - inject(message: unknown) { - transport.onmessage?.(message as JSONRPCMessage); - }, - /** Messages sent after the given index. */ - since(index: number) { - return sent.slice(index); - }, - }; -} - -const hostInfo = { name: "TestHost", version: "9.9.9" }; -const hostCapabilities: McpUiHostCapabilities = { - openLinks: {}, - serverTools: {}, - serverResources: {}, - logging: {}, - updateModelContext: {}, -}; -const hostContext: McpUiHostContext = { - theme: "dark", - displayMode: "inline", - locale: "en-US", -}; - -/** Shaped like ext-apps 1.7.x View traffic (sdk-1 key order, 0-based numeric ids); not a recorded capture. */ -const v1View = { - initialize: { - method: "ui/initialize", - params: { - appCapabilities: { tools: { listChanged: true } }, - appInfo: { name: "TestView", version: "1.2.3" }, - protocolVersion: "2026-01-26", - }, - jsonrpc: "2.0", - id: 0, - }, - initialized: { method: "ui/notifications/initialized", jsonrpc: "2.0" }, - callTool: { - method: "tools/call", - params: { name: "echo", arguments: { a: 1 }, _meta: { progressToken: 6 } }, - jsonrpc: "2.0", - id: 6, - }, - readResource: { - method: "resources/read", - params: { uri: "test://x" }, - jsonrpc: "2.0", - id: 12, - }, - toolsListResult: (id: number) => ({ - result: { - tools: [ - { - name: "viewtool", - description: "view tool", - inputSchema: { - type: "object", - properties: { x: { type: "number" } }, - }, - }, - ], - }, - jsonrpc: "2.0", - id, - }), -}; - -/** Shaped like ext-apps 1.7.x AppBridge traffic (sdk 1.x); not a recorded capture. */ -const v1Host = { - initializeResult: (id: number) => ({ - result: { - protocolVersion: "2026-01-26", - hostCapabilities, - hostInfo, - hostContext, - }, - jsonrpc: "2.0", - id, - }), - toolInput: { - method: "ui/notifications/tool-input", - params: { arguments: { location: "NYC" } }, - jsonrpc: "2.0", - }, - callToolResult: (id: number) => ({ - result: { content: [{ type: "text", text: "echo 1" }] }, - jsonrpc: "2.0", - id, - }), - /** McpServer 1.x reports an unknown tool as an isError result, not an error. */ - callToolUnknownResult: (id: number) => ({ - result: { - content: [ - { type: "text", text: "MCP error -32602: Tool nope not found" }, - ], - isError: true, - }, - jsonrpc: "2.0", - id, - }), - resourceNotFoundError: (id: number) => ({ - jsonrpc: "2.0", - id, - error: { - code: -32002, - message: "MCP error -32002: Resource not found (host-thrown -32002)", - }, - }), - toolsList: { method: "tools/list", jsonrpc: "2.0", id: 0 }, - callViewTool: { - method: "tools/call", - params: { name: "viewtool", arguments: { x: 7 } }, - jsonrpc: "2.0", - id: 1, - }, -}; - -describe("wire compatibility: 2.x AppBridge with a 1.x View", () => { - let bridge: AppBridge; - - afterEach(async () => { - await bridge?.close().catch(() => {}); - }); - - async function connectBridge() { - const channel = createRawChannel(); - bridge = new AppBridge(null, hostInfo, hostCapabilities, { hostContext }); - await bridge.connect(channel.transport); - return channel; - } - - async function handshake() { - const channel = await connectBridge(); - let initialized = 0; - bridge.oninitialized = () => { - initialized++; - }; - channel.inject(v1View.initialize); - await flush(); - channel.inject(v1View.initialized); - await flush(); - return { channel, initialized: () => initialized }; - } - - it("sends nothing on connect: no MCP initialize toward the View", async () => { - const channel = await connectBridge(); - await flush(); - expect(channel.sent).toEqual([]); - }); - - it("answers a 1.x ui/initialize with the 1.x result shape and same id", async () => { - const channel = await connectBridge(); - channel.inject(v1View.initialize); - await flush(); - - expect(channel.sent).toEqual([ - { - jsonrpc: "2.0", - id: 0, - result: { - protocolVersion: LATEST_PROTOCOL_VERSION, - hostCapabilities, - hostInfo, - hostContext, - }, - }, - ]); - expect(bridge.getAppCapabilities()).toEqual({ - tools: { listChanged: true }, - }); - expect(bridge.getAppVersion()).toEqual({ - name: "TestView", - version: "1.2.3", - }); - }); - - it("accepts ui/notifications/initialized without params", async () => { - const { channel, initialized } = await handshake(); - expect(initialized()).toBe(1); - // Only the initialize response went out; the notification gets no reply - // and the bridge never starts an MCP handshake of its own. - expect(channel.sent).toHaveLength(1); - expect(channel.sent.some((m) => "method" in m)).toBe(false); - }); - - it("answers a 1.x tools/call request through oncalltool", async () => { - const { channel } = await handshake(); - bridge.oncalltool = async (params) => ({ - content: [{ type: "text", text: `echo ${params.arguments?.a}` }], - }); - const mark = channel.sent.length; - channel.inject(v1View.callTool); - await flush(); - - expect(channel.since(mark)).toEqual([ - { - jsonrpc: "2.0", - id: 6, - result: { content: [{ type: "text", text: "echo 1" }] }, - }, - ]); - }); - - it("re-encodes a handler-thrown -32002 as -32602 without the MCP error prefix", async () => { - // Documented host-side delta: SDK 2.x never emits -32002 on the wire. - const { channel } = await handshake(); - bridge.onreadresource = async () => { - throw new ProtocolError( - -32002, - "Resource not found (host-thrown -32002)", - ); - }; - const mark = channel.sent.length; - channel.inject(v1View.readResource); - await flush(); - - const [reply] = channel.since(mark); - expect(reply).toMatchObject({ - jsonrpc: "2.0", - id: 12, - error: { code: -32602 }, - }); - const message = (reply as { error: { message: string } }).error.message; - expect(message).toBe("Resource not found (host-thrown -32002)"); - expect(message).not.toMatch(/^MCP error/); - }); - - it("rejects invalid params on a ui/* request with -32602", async () => { - const { channel } = await handshake(); - bridge.onopenlink = async () => ({}); - const mark = channel.sent.length; - channel.inject({ - method: "ui/open-link", - params: { url: 42 }, - jsonrpc: "2.0", - id: 14, - }); - await flush(); - - expect(channel.since(mark)).toHaveLength(1); - expect(channel.since(mark)[0]).toMatchObject({ - jsonrpc: "2.0", - id: 14, - error: { code: -32602 }, - }); - }); - - it("lists and calls View tools with 1.x-shaped responses", async () => { - const { channel } = await handshake(); - const mark = channel.sent.length; - const listPromise = bridge.listTools({}); - await flush(); - - const [listRequest] = channel.since(mark) as Array<{ - id: number; - method: string; - }>; - expect(listRequest).toMatchObject({ jsonrpc: "2.0", method: "tools/list" }); - channel.inject(v1View.toolsListResult(listRequest.id)); - const list = await listPromise; - expect(list.tools.map((t) => t.name)).toEqual(["viewtool"]); - - const callPromise = bridge.callTool({ - name: "viewtool", - arguments: { x: 7 }, - }); - await flush(); - const [callRequest] = channel.since(mark + 1) as Array<{ id: number }>; - expect(callRequest).toMatchObject({ - jsonrpc: "2.0", - method: "tools/call", - params: { name: "viewtool", arguments: { x: 7 } }, - }); - channel.inject({ - result: { content: [{ type: "text", text: "view got 7" }] }, - jsonrpc: "2.0", - id: callRequest.id, - }); - expect(await callPromise).toEqual({ - content: [{ type: "text", text: "view got 7" }], - }); - }); - - it("only ever emits jsonrpc 2.0 messages, never an initialize request", async () => { - const { channel } = await handshake(); - bridge.oncalltool = async () => ({ content: [] }); - channel.inject(v1View.callTool); - bridge.sendToolInput({ arguments: { q: "x" } }); - bridge.sendToolResult({ content: [] }); - await flush(); - - for (const message of channel.sent) { - expect(message.jsonrpc).toBe("2.0"); - } - const methods = channel.sent - .filter((m): m is JSONRPCMessage & { method: string } => "method" in m) - .map((m) => m.method); - expect(methods).toEqual([ - "ui/notifications/tool-input", - "ui/notifications/tool-result", - ]); - expect(methods).not.toContain("initialize"); - expect(methods).not.toContain("notifications/initialized"); - }); -}); - -describe("wire compatibility: 2.x App with a 1.x host", () => { - let app: App; - - afterEach(async () => { - await app?.close().catch(() => {}); - }); - - /** Connect the App and answer its ui/initialize the way a 1.x host did. */ - async function handshake( - capabilities: ConstructorParameters<typeof App>[1] = {}, - setup: (app: App) => void = () => {}, - ) { - const channel = createRawChannel(); - app = new App({ name: "TestView", version: "1.2.3" }, capabilities, { - autoResize: false, - }); - setup(app); - const connected = app.connect(channel.transport); - await flush(); - - const [initialize] = channel.sent as Array<{ id: number }>; - channel.inject(v1Host.initializeResult(initialize.id)); - await connected; - await flush(); - return { channel, initialize }; - } - - it("sends a 1.x-compatible ui/initialize and ui/notifications/initialized", async () => { - const { channel } = await handshake({ tools: { listChanged: true } }); - - expect(channel.sent).toEqual([ - { - jsonrpc: "2.0", - id: 0, - method: "ui/initialize", - params: { - protocolVersion: LATEST_PROTOCOL_VERSION, - appInfo: { name: "TestView", version: "1.2.3" }, - appCapabilities: { tools: { listChanged: true } }, - }, - }, - { jsonrpc: "2.0", method: "ui/notifications/initialized" }, - ]); - expect(app.getHostContext()).toEqual(hostContext); - expect(app.getHostCapabilities()).toEqual(hostCapabilities); - expect(app.getHostVersion()).toEqual(hostInfo); - }); - - it("delivers a 1.x notification with sdk-1 key order to ontoolinput", async () => { - const received: unknown[] = []; - const { channel } = await handshake({}, (app) => { - app.ontoolinput = (params) => { - received.push(params); - }; - }); - channel.inject(v1Host.toolInput); - await flush(); - expect(received).toEqual([{ arguments: { location: "NYC" } }]); - }); - - it("resolves callServerTool from a 1.x tools/call response", async () => { - const { channel } = await handshake(); - const mark = channel.sent.length; - const promise = app.callServerTool({ name: "echo", arguments: { a: 1 } }); - await flush(); - - const [request] = channel.since(mark) as Array<{ id: number }>; - expect(request).toMatchObject({ - jsonrpc: "2.0", - method: "tools/call", - params: { - name: "echo", - arguments: { a: 1 }, - _meta: { progressToken: request.id }, - }, - }); - channel.inject(v1Host.callToolResult(request.id)); - expect(await promise).toEqual({ - content: [{ type: "text", text: "echo 1" }], - }); - }); - - it("passes through a 1.x isError tool result unchanged", async () => { - const { channel } = await handshake(); - const mark = channel.sent.length; - const promise = app.callServerTool({ name: "nope", arguments: {} }); - await flush(); - const [request] = channel.since(mark) as Array<{ id: number }>; - channel.inject(v1Host.callToolUnknownResult(request.id)); - expect(await promise).toEqual({ - content: [ - { type: "text", text: "MCP error -32602: Tool nope not found" }, - ], - isError: true, - }); - }); - - it("surfaces a 1.x -32002 error as a ProtocolError with code -32002", async () => { - const { channel } = await handshake(); - const mark = channel.sent.length; - const promise = app.readServerResource({ uri: "test://x" }); - await flush(); - const [request] = channel.since(mark) as Array<{ id: number }>; - channel.inject(v1Host.resourceNotFoundError(request.id)); - - const error = await promise.then( - () => undefined, - (e: unknown) => e, - ); - expect(error).toBeInstanceOf(ProtocolError); - expect((error as ProtocolError).code).toBe(-32002); - expect((error as ProtocolError).message).toContain( - "Resource not found (host-thrown -32002)", - ); - }); - - it("answers 1.x tools/list (no params) and tools/call requests from the host", async () => { - const { channel } = await handshake({ tools: { listChanged: true } }); - app.registerTool( - "viewtool", - { description: "view tool", inputSchema: z.object({ x: z.number() }) }, - async ({ x }) => ({ - content: [{ type: "text", text: `view got ${x}` }], - }), - ); - await flush(); - const mark = channel.sent.length; - - channel.inject(v1Host.toolsList); - await flush(); - const [listReply] = channel.since(mark) as unknown as Array<{ - result: { tools: Array<{ name: string; inputSchema: unknown }> }; - }>; - expect(listReply).toMatchObject({ jsonrpc: "2.0", id: 0 }); - expect(listReply.result.tools).toHaveLength(1); - expect(listReply.result.tools[0]).toMatchObject({ - name: "viewtool", - description: "view tool", - inputSchema: { - type: "object", - properties: { x: { type: "number" } }, - required: ["x"], - }, - }); - - channel.inject(v1Host.callViewTool); - await flush(); - expect(channel.since(mark + 1)).toEqual([ - { - jsonrpc: "2.0", - id: 1, - result: { content: [{ type: "text", text: "view got 7" }] }, - }, - ]); - }); -}); - -describe("AppBridge proxy over a default (modern era) 2.x Client/Server", () => { - let client: Client; - let server: McpServer; - let app: App; - let bridge: AppBridge; - - afterEach(async () => { - await app?.close().catch(() => {}); - await bridge?.close().catch(() => {}); - await client?.close().catch(() => {}); - await server?.close().catch(() => {}); - }); - - it("forwards tools/call and tools/list_changed with default negotiation", async () => { - server = new McpServer({ name: "ActualServer", version: "1.0.0" }); - server.registerTool( - "echo", - { inputSchema: z.object({ a: z.number() }) }, - async ({ a }) => ({ content: [{ type: "text", text: `echo ${a}` }] }), - ); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - - client = new Client({ name: "HostOuterClient", version: "1.0.0" }); - await client.connect(clientTransport); - expect(client.getServerCapabilities()?.tools).toEqual({ - listChanged: true, - }); - - bridge = new AppBridge(client, hostInfo, hostCapabilities, { - hostContext, - }); - app = new App( - { name: "TestView", version: "1.2.3" }, - {}, - { - autoResize: false, - }, - ); - const listChanged: string[] = []; - app.setNotificationHandler("notifications/tools/list_changed", () => { - listChanged.push("tools"); - }); - const [appTransport, bridgeTransport] = - InMemoryTransport.createLinkedPair(); - await bridge.connect(bridgeTransport); - await app.connect(appTransport); - - expect( - await app.callServerTool({ name: "echo", arguments: { a: 1 } }), - ).toMatchObject({ content: [{ type: "text", text: "echo 1" }] }); - - server.sendToolListChanged(); - await flush(); - expect(listChanged).toEqual(["tools"]); - }); -}); diff --git a/tests/e2e/generate-grid-screenshots.spec.ts b/tests/e2e/generate-grid-screenshots.spec.ts index bbe8959..3f32630 100644 --- a/tests/e2e/generate-grid-screenshots.spec.ts +++ b/tests/e2e/generate-grid-screenshots.spec.ts @@ -10,7 +10,7 @@ * integration-server is excluded (it's for E2E testing, same UI as basic-server-react). */ -import { test, expect, type Page } from "@playwright/test"; +import { test, type Page } from "@playwright/test"; import * as path from "path"; import * as fs from "fs"; import sharp from "sharp"; @@ -28,15 +28,10 @@ const EXTRA_WAIT_MS: Record<string, number> = { // Servers to skip (screenshots maintained manually) const SKIP_SERVERS = new Set([ "video-resource", // Uses custom screenshot from PR comment - "qr-server", // Uses custom screenshot from PR comment - "say-server", // TTS model download from HuggingFace can be slow ]); -// Optional: filter to a single example via EXAMPLE env var (folder name) -const EXAMPLE_FILTER = process.env.EXAMPLE; - // Server configurations (excludes integration-server which is for E2E testing) -const ALL_SERVERS = [ +const SERVERS = [ { key: "basic-react", name: "Basic MCP App Server (React)", @@ -57,11 +52,8 @@ const ALL_SERVERS = [ name: "Customer Segmentation Server", dir: "customer-segmentation-server", }, - { key: "debug-server", name: "Debug MCP App Server", dir: "debug-server" }, - { key: "map-server", name: "CesiumJS Map Server", dir: "map-server" }, + { key: "map-server", name: "Map Server", dir: "map-server" }, { key: "pdf-server", name: "PDF Server", dir: "pdf-server" }, - { key: "qr-server", name: "QR Code Server", dir: "qr-server" }, - { key: "say-server", name: "Say Demo", dir: "say-server" }, { key: "scenario-modeler", name: "SaaS Scenario Modeler", @@ -88,11 +80,6 @@ const ALL_SERVERS = [ { key: "wiki-explorer", name: "Wiki Explorer", dir: "wiki-explorer-server" }, ]; -// Filter servers if EXAMPLE is specified -const SERVERS = EXAMPLE_FILTER - ? ALL_SERVERS.filter((s) => s.dir === EXAMPLE_FILTER) - : ALL_SERVERS; - /** * Wait for the MCP App to load inside nested iframes. */ @@ -105,13 +92,15 @@ async function waitForAppLoad(page: Page) { /** * Load a server by selecting it from dropdown and clicking Call Tool. - * Uses ?theme=hide to hide the theme toggle for consistent screenshots. */ async function loadServer(page: Page, serverName: string) { - await page.goto("/?theme=hide"); - // Wait for servers to connect (select becomes enabled when servers are ready) - await expect(page.locator("select").first()).toBeEnabled({ timeout: 30000 }); - await page.locator("select").first().selectOption({ label: serverName }); + await page.goto("/"); + await page + .locator("select") + .nth(0) + .waitFor({ state: "visible", timeout: 30000 }); + await page.waitForTimeout(500); + await page.locator("select").nth(0).selectOption({ label: serverName }); await page.click('button:has-text("Call Tool")'); await waitForAppLoad(page); } diff --git a/tests/e2e/pdf-annotations-api.spec.ts b/tests/e2e/pdf-annotations-api.spec.ts deleted file mode 100644 index 5e63237..0000000 --- a/tests/e2e/pdf-annotations-api.spec.ts +++ /dev/null @@ -1,333 +0,0 @@ -/** - * PDF Annotation โ€” Claude API prompt discovery tests - * - * Tests that Claude can discover and use PDF annotation capabilities - * when given the display_pdf result and interact tool descriptions. - * - * These tests call the Anthropic API directly and are DISABLED by default. - * To enable: - * ANTHROPIC_API_KEY=sk-ant-... npx playwright test tests/e2e/pdf-annotations-api.spec.ts - */ - -import { test, expect } from "@playwright/test"; - -const API_KEY = process.env.ANTHROPIC_API_KEY; - -// Skip all tests if no API key -test.skip(!API_KEY, "Set ANTHROPIC_API_KEY to run API prompt tests"); -test.setTimeout(60000); - -// Tool definitions matching the PDF server's registered tools -const TOOLS = [ - { - name: "display_pdf", - description: - "Display a PDF in an interactive viewer. Returns a viewUUID for subsequent interact calls.", - input_schema: { - type: "object" as const, - properties: { - url: { - type: "string", - description: "URL or path to the PDF file", - default: "https://arxiv.org/pdf/1706.03762", - }, - page: { - type: "number", - description: "Initial page to display (1-indexed)", - minimum: 1, - }, - }, - }, - }, - { - name: "interact", - description: `Interact with a PDF viewer: annotate, navigate, search, extract pages, fill forms. - -**ANNOTATION** โ€” You can add visual annotations to any page. Use add_annotations with an array of annotation objects. -Each annotation needs: id (unique string), type, page (1-indexed). -Coordinates use PDF points (72 dpi), bottom-left origin. - -Annotation types: -โ€ข highlight: rects:[{x,y,width,height}], color?, content? โ€” semi-transparent overlay on text regions -โ€ข underline: rects:[{x,y,width,height}], color? โ€” underline below text -โ€ข strikethrough: rects:[{x,y,width,height}], color? โ€” line through text -โ€ข note: x, y, content, color? โ€” sticky note icon with tooltip -โ€ข rectangle: x, y, width, height, color?, fillColor? โ€” outlined/filled box -โ€ข freetext: x, y, content, fontSize?, color? โ€” arbitrary text label -โ€ข stamp: x, y, label (APPROVED|DRAFT|CONFIDENTIAL|FINAL|VOID|REJECTED), color?, rotation? โ€” stamp overlay - -Example โ€” add a highlight and a stamp on page 1: -\`\`\`json -{"action":"add_annotations","viewUUID":"โ€ฆ","annotations":[ - {"id":"h1","type":"highlight","page":1,"rects":[{"x":72,"y":700,"width":200,"height":12}]}, - {"id":"s1","type":"stamp","page":1,"x":300,"y":500,"label":"APPROVED","color":"green","rotation":-15} -]} -\`\`\` - -**HIGHLIGHT TEXT** โ€” highlight_text: auto-find and highlight text by query. Requires \`query\`. Optional: page, color, content. - -**ANNOTATION MANAGEMENT**: -โ€ข update_annotations: partial update (id+type required). โ€ข remove_annotations: remove by ids. - -**NAVIGATION & SEARCH**: -โ€ข navigate: go to page (requires \`page\`) -โ€ข search: highlight matches in UI (requires \`query\`). Results in model context. -โ€ข find: silent search, no UI change (requires \`query\`). Results in model context. -โ€ข search_navigate: jump to match (requires \`matchIndex\`) -โ€ข zoom: set scale 0.5โ€“3.0 (requires \`scale\`) - -**PAGE EXTRACTION** โ€” get_pages: extract text/screenshots from page ranges without navigating. \`intervals\` = [{start?,end?}], e.g. [{}] for all. \`getText\` (default true), \`getScreenshots\` (default false). Max 20 pages. - -**FORMS** โ€” fill_form: fill fields with \`fields\` array of {name, value}.`, - input_schema: { - type: "object" as const, - required: ["viewUUID", "action"], - properties: { - viewUUID: { - type: "string", - description: - "The viewUUID of the PDF viewer (from display_pdf result)", - }, - action: { - type: "string", - enum: [ - "navigate", - "search", - "find", - "search_navigate", - "zoom", - "add_annotations", - "update_annotations", - "remove_annotations", - "highlight_text", - "fill_form", - "get_pages", - ], - description: "Action to perform", - }, - page: { - type: "number", - minimum: 1, - description: "Page number (for navigate, highlight_text)", - }, - query: { - type: "string", - description: "Search text (for search / find / highlight_text)", - }, - matchIndex: { - type: "number", - minimum: 0, - description: "Match index (for search_navigate)", - }, - scale: { - type: "number", - minimum: 0.5, - maximum: 3.0, - description: "Zoom scale (for zoom)", - }, - annotations: { - type: "array", - description: - "Annotation objects for add_annotations or update_annotations", - items: { type: "object" }, - }, - ids: { - type: "array", - items: { type: "string" }, - description: "Annotation IDs (for remove_annotations)", - }, - color: { - type: "string", - description: "Color override (for highlight_text)", - }, - content: { - type: "string", - description: "Tooltip/note content (for highlight_text)", - }, - fields: { - type: "array", - items: { type: "object" }, - description: "Form fields (for fill_form)", - }, - intervals: { - type: "array", - items: { type: "object" }, - description: "Page ranges for get_pages", - }, - getText: { - type: "boolean", - description: "Include text (for get_pages, default true)", - }, - getScreenshots: { - type: "boolean", - description: "Include screenshots (for get_pages, default false)", - }, - }, - }, - }, -]; - -// Simulated display_pdf result the model would see after calling display_pdf -const DISPLAY_PDF_RESULT_TEXT = `Displaying PDF (viewUUID: abc-123-def): https://arxiv.org/pdf/1706.03762. - -Use the \`interact\` tool with this viewUUID. Available actions: -- navigate: go to a page -- search / find: search text (search highlights in UI, find is silent) -- search_navigate: jump to a search match by index -- zoom: set zoom level (0.5โ€“3.0) -- add_annotations: add highlights, underlines, strikethroughs, notes, rectangles, freetext, stamps (APPROVED/DRAFT/CONFIDENTIAL/FINAL/VOID/REJECTED) -- update_annotations: partially update existing annotations -- remove_annotations: remove annotations by ID -- highlight_text: find text by query and highlight it automatically -- fill_form: fill PDF form fields -- get_pages: extract text and/or screenshots from page ranges without navigating`; - -/** - * Conversation history simulating: user asked to display a PDF, model called - * display_pdf, and the tool returned the result above. - */ -const AFTER_DISPLAY_PDF = [ - { - role: "user" as const, - content: "Show me the Attention Is All You Need paper", - }, - { - role: "assistant" as const, - content: [ - { - type: "tool_use" as const, - id: "toolu_display_1", - name: "display_pdf", - input: { url: "https://arxiv.org/pdf/1706.03762" }, - }, - ], - }, - { - role: "user" as const, - content: [ - { - type: "tool_result" as const, - tool_use_id: "toolu_display_1", - content: DISPLAY_PDF_RESULT_TEXT, - }, - ], - }, -]; - -/** Call the Anthropic Messages API and return parsed response. */ -async function callClaude( - messages: Array<{ role: string; content: unknown }>, -): Promise<{ - toolUses: Array<{ name: string; input: Record<string, unknown> }>; - textBlocks: Array<{ text: string }>; -}> { - const response = await fetch("https://api.anthropic.com/v1/messages", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": API_KEY!, - "anthropic-version": "2023-06-01", - }, - body: JSON.stringify({ - model: "claude-sonnet-4-20250514", - max_tokens: 1024, - tools: TOOLS, - messages, - }), - }); - - expect(response.ok, `API error ${response.status}`).toBe(true); - const result = await response.json(); - - return { - toolUses: result.content.filter( - (c: { type: string }) => c.type === "tool_use", - ), - textBlocks: result.content.filter( - (c: { type: string }) => c.type === "text", - ), - }; -} - -/** Check if any tool call uses an annotation-related interact action. */ -function usesAnnotationAction( - toolUses: Array<{ name: string; input: Record<string, unknown> }>, -): boolean { - return toolUses.some( - (tu) => - tu.name === "interact" && - ["add_annotations", "highlight_text", "update_annotations"].includes( - tu.input.action as string, - ), - ); -} - -/** Check if any tool call uses the interact tool. */ -function usesInteract( - toolUses: Array<{ name: string; input: Record<string, unknown> }>, -): boolean { - return toolUses.some((tu) => tu.name === "interact"); -} - -/** Check if text mentions annotation capabilities. */ -function mentionsAnnotations(textBlocks: Array<{ text: string }>): boolean { - const text = textBlocks - .map((t) => t.text) - .join(" ") - .toLowerCase(); - return ( - text.includes("annotati") || - text.includes("highlight") || - text.includes("stamp") || - text.includes("add_annotations") || - text.includes("mark up") - ); -} - -test.describe("PDF Annotation โ€” API prompt discovery", () => { - test("model uses highlight_text when asked to highlight the title", async () => { - const { toolUses } = await callClaude([ - ...AFTER_DISPLAY_PDF, - { - role: "user", - content: - "Please highlight the title and add an APPROVED stamp on the first page.", - }, - ]); - - expect(toolUses.length).toBeGreaterThan(0); - expect(usesAnnotationAction(toolUses)).toBe(true); - }); - - test("model discovers annotation capabilities when asked 'can you annotate?'", async () => { - const { toolUses, textBlocks } = await callClaude([ - ...AFTER_DISPLAY_PDF, - { - role: "user", - content: "Can you annotate this PDF? Mark important sections for me.", - }, - ]); - - // Model should either use annotations directly or acknowledge it can - const discovers = - usesAnnotationAction(toolUses) || - usesInteract(toolUses) || - mentionsAnnotations(textBlocks); - expect(discovers).toBe(true); - }); - - test("model uses add_annotations or get_pages when asked to add notes", async () => { - const { toolUses } = await callClaude([ - ...AFTER_DISPLAY_PDF, - { - role: "user", - content: - "Add a note on page 1 saying 'Key contribution' at position (200, 500), and highlight the abstract.", - }, - ]); - - expect(toolUses.length).toBeGreaterThan(0); - // Model should use interact (either to annotate directly or to read first) - expect(usesInteract(toolUses)).toBe(true); - }); -}); diff --git a/tests/e2e/pdf-annotations.spec.ts b/tests/e2e/pdf-annotations.spec.ts deleted file mode 100644 index af67bb7..0000000 --- a/tests/e2e/pdf-annotations.spec.ts +++ /dev/null @@ -1,406 +0,0 @@ -import { test, expect, type Page } from "@playwright/test"; - -// Increase timeout for these tests โ€” PDF loading from arxiv can be slow -test.setTimeout(120000); - -/** - * PDF Annotation E2E Tests - * - * Tests the annotation capabilities of the PDF server through the basic-host UI. - * Verifies that annotations can be added, rendered, and interacted with. - */ - -/** Wait for the MCP App to load inside nested iframes. */ -async function waitForAppLoad(page: Page) { - const outerFrame = page.frameLocator("iframe").first(); - await expect(outerFrame.locator("iframe")).toBeVisible({ timeout: 30000 }); -} - -/** Get the app frame locator (nested: sandbox > app) */ -function getAppFrame(page: Page) { - return page.frameLocator("iframe").first().frameLocator("iframe").first(); -} - -/** Load the PDF server and call display_pdf with the default PDF. */ -async function loadPdfServer(page: Page) { - await page.goto("/?theme=hide"); - await expect(page.locator("select").first()).toBeEnabled({ timeout: 30000 }); - await page.locator("select").first().selectOption({ label: "PDF Server" }); - await page.click('button:has-text("Call Tool")'); - await waitForAppLoad(page); -} - -/** - * Extract the viewUUID from the display_pdf result panel. - * The tool result is displayed as JSON in a collapsible panel. - */ -async function extractViewUUID(page: Page): Promise<string> { - // Wait for the Tool Result panel to appear โ€” it contains "๐Ÿ“ค Tool Result" - const resultPanel = page.locator('text="๐Ÿ“ค Tool Result"').first(); - await expect(resultPanel).toBeVisible({ timeout: 30000 }); - - // The result preview shows the first 100 chars including "viewUUID: ..." - // Click to expand the result panel to see the full JSON - await resultPanel.click(); - - // Wait for the expanded result content to appear - const resultContent = page.locator("pre").last(); - await expect(resultContent).toBeVisible({ timeout: 5000 }); - - const resultText = (await resultContent.textContent()) ?? ""; - - // Extract viewUUID from the JSON result - // The text content includes: "PDF opened. viewUUID: <uuid>" - const match = resultText.match(/viewUUID["\s:]+([a-f0-9-]{36})/); - if (!match) { - throw new Error( - `Could not extract viewUUID from result: ${resultText.slice(0, 200)}`, - ); - } - return match[1]; -} - -/** - * Call the interact tool with the given input JSON. - * Selects the interact tool from the dropdown, fills the input, and clicks Call Tool. - */ -async function callInteract(page: Page, input: Record<string, unknown>) { - // Select "interact" in the tool dropdown (second select on the page) - const toolSelect = page.locator("select").nth(1); - await toolSelect.selectOption("interact"); - - // Fill the input textarea with the JSON - const inputTextarea = page.locator("textarea"); - await inputTextarea.fill(JSON.stringify(input)); - - // Click "Call Tool" - await page.click('button:has-text("Call Tool")'); -} - -/** Wait for the PDF canvas to render (ensures the page is ready for annotations). */ -async function waitForPdfCanvas(page: Page) { - const appFrame = getAppFrame(page); - await expect(appFrame.locator("canvas").first()).toBeVisible({ - timeout: 30000, - }); - // Wait a bit for fonts and text layer to stabilize - await page.waitForTimeout(2000); -} - -test.describe("PDF Server - Annotations", () => { - test("display_pdf result includes viewUUID and interactEnabled meta", async ({ - page, - }) => { - await loadPdfServer(page); - - // Wait for result to appear - const resultPanel = page.locator('text="๐Ÿ“ค Tool Result"').first(); - await expect(resultPanel).toBeVisible({ timeout: 30000 }); - - // Expand the result panel - await resultPanel.click(); - const resultContent = page.locator("pre").last(); - await expect(resultContent).toBeVisible({ timeout: 5000 }); - const resultText = (await resultContent.textContent()) ?? ""; - - // The result should carry the viewUUID and interact flag in _meta / structuredContent - // (interact action list lives in the tool description, not the runtime result). - expect(resultText).toContain("viewUUID"); - expect(resultText).toContain("interactEnabled"); - expect(resultText).toContain("PDF opened"); - }); - - test("interact tool is available in tool dropdown", async ({ page }) => { - await loadPdfServer(page); - - // Verify the interact tool is available in the tool dropdown - const toolSelect = page.locator("select").nth(1); - const options = await toolSelect.locator("option").allTextContents(); - expect(options).toContain("interact"); - }); - - test("add_annotations renders highlight on the page", async ({ page }) => { - await loadPdfServer(page); - await waitForPdfCanvas(page); - - const viewUUID = await extractViewUUID(page); - - // Add a highlight annotation on page 1 - await callInteract(page, { - viewUUID, - action: "add_annotations", - annotations: [ - { - id: "test-highlight-1", - type: "highlight", - page: 1, - rects: [{ x: 72, y: 700, width: 300, height: 14 }], - color: "rgba(255, 255, 0, 0.4)", - }, - ], - }); - - // Wait for the interact result - await page.waitForTimeout(1000); - - // Verify the annotation appears in the annotation layer inside the app frame - const appFrame = getAppFrame(page); - const annotationLayer = appFrame.locator("#annotation-layer"); - await expect(annotationLayer).toBeVisible({ timeout: 5000 }); - - // Check that a highlight annotation element was rendered - const highlightEl = appFrame.locator(".annotation-highlight"); - await expect(highlightEl.first()).toBeVisible({ timeout: 5000 }); - // Regression: highlight must be translucent (not opaque hex), so text - // underneath remains readable. - await expect(highlightEl.first()).toHaveCSS( - "background-color", - /rgba\(255, 255, 0, 0\.35\)/, - ); - }); - - test("add_annotations renders multiple annotation types", async ({ - page, - }) => { - await loadPdfServer(page); - await waitForPdfCanvas(page); - - const viewUUID = await extractViewUUID(page); - - // Add multiple annotation types at once - await callInteract(page, { - viewUUID, - action: "add_annotations", - annotations: [ - { - id: "test-highlight", - type: "highlight", - page: 1, - rects: [{ x: 72, y: 700, width: 300, height: 14 }], - color: "rgba(255, 255, 0, 0.4)", - }, - { - id: "test-note", - type: "note", - page: 1, - x: 400, - y: 600, - content: "Important finding!", - color: "#ffeb3b", - }, - { - id: "test-stamp", - type: "stamp", - page: 1, - x: 300, - y: 400, - label: "APPROVED", - color: "#4caf50", - rotation: -15, - }, - { - id: "test-freetext", - type: "freetext", - page: 1, - x: 100, - y: 300, - content: "See section 3.2", - fontSize: 14, - color: "#1976d2", - }, - { - id: "test-rect", - type: "rectangle", - page: 1, - x: 50, - y: 200, - width: 500, - height: 100, - color: "#f44336", - }, - ], - }); - - await page.waitForTimeout(1500); - - const appFrame = getAppFrame(page); - - // Verify each annotation type is rendered - await expect(appFrame.locator(".annotation-highlight").first()).toBeVisible( - { - timeout: 5000, - }, - ); - await expect(appFrame.locator(".annotation-note").first()).toBeVisible({ - timeout: 5000, - }); - await expect(appFrame.locator(".annotation-stamp").first()).toBeVisible({ - timeout: 5000, - }); - await expect(appFrame.locator(".annotation-freetext").first()).toBeVisible({ - timeout: 5000, - }); - await expect(appFrame.locator(".annotation-rectangle").first()).toBeVisible( - { timeout: 5000 }, - ); - }); - - test("remove_annotations removes annotation from DOM", async ({ page }) => { - await loadPdfServer(page); - await waitForPdfCanvas(page); - - const viewUUID = await extractViewUUID(page); - - // Add an annotation - await callInteract(page, { - viewUUID, - action: "add_annotations", - annotations: [ - { - id: "to-remove", - type: "highlight", - page: 1, - rects: [{ x: 72, y: 700, width: 300, height: 14 }], - }, - ], - }); - - await page.waitForTimeout(1000); - - const appFrame = getAppFrame(page); - await expect(appFrame.locator(".annotation-highlight").first()).toBeVisible( - { - timeout: 5000, - }, - ); - - // Remove the annotation - await callInteract(page, { - viewUUID, - action: "remove_annotations", - ids: ["to-remove"], - }); - - await page.waitForTimeout(1000); - - // Verify the annotation is gone - await expect(appFrame.locator(".annotation-highlight")).toHaveCount(0, { - timeout: 5000, - }); - }); - - test("highlight_text finds and highlights text", async ({ page }) => { - await loadPdfServer(page); - await waitForPdfCanvas(page); - - const viewUUID = await extractViewUUID(page); - - // Use highlight_text to find and highlight "Attention" in the PDF - await callInteract(page, { - viewUUID, - action: "highlight_text", - query: "Attention", - color: "rgba(0, 200, 255, 0.4)", - }); - - await page.waitForTimeout(2000); - - const appFrame = getAppFrame(page); - // highlight_text creates highlight annotations, so we should see at least one - await expect(appFrame.locator(".annotation-highlight").first()).toBeVisible( - { - timeout: 10000, - }, - ); - }); -}); - -/** - * Read the most recent interact result text from the basic-host UI. - * Waits for the result-panel count to reach `expectedCount` first โ€” - * `callInteract` doesn't block, so `.last()` would otherwise race to the - * previous (display_pdf) panel. - */ -async function readLastToolResult( - page: Page, - expectedCount: number, -): Promise<string> { - const panels = page.locator('text="๐Ÿ“ค Tool Result"'); - await expect(panels).toHaveCount(expectedCount, { timeout: 30000 }); - await panels.last().click(); - const pre = page.locator("pre").last(); - await expect(pre).toBeVisible({ timeout: 5000 }); - return (await pre.textContent()) ?? ""; -} - -/** Unwrap basic-host's `CallToolResult` JSON to the first text block. */ -function unwrapTextResult(raw: string): string { - const parsed = JSON.parse(raw) as { - content?: { type: string; text?: string }[]; - }; - const block = parsed.content?.find((c) => c.type === "text"); - if (!block?.text) throw new Error(`No text block in: ${raw.slice(0, 200)}`); - return block.text; -} - -test.describe("PDF Server - get_viewer_state", () => { - test("returns page/zoom/mode and selection:null when nothing is selected", async ({ - page, - }) => { - await loadPdfServer(page); - await waitForPdfCanvas(page); - - const viewUUID = await extractViewUUID(page); - - await callInteract(page, { viewUUID, action: "get_viewer_state" }); - const raw = await readLastToolResult(page, 2); - const state = JSON.parse(unwrapTextResult(raw)); - - expect(state.currentPage).toBe(1); - expect(state.pageCount).toBeGreaterThan(1); - expect(typeof state.zoom).toBe("number"); - expect(state.displayMode).toBe("inline"); - expect(state.selection).toBeNull(); - expect(Array.isArray(state.selectedAnnotationIds)).toBe(true); - }); - - test("returns selected text and bounding rect when text-layer text is selected", async ({ - page, - }) => { - await loadPdfServer(page); - await waitForPdfCanvas(page); - - const viewUUID = await extractViewUUID(page); - const app = getAppFrame(page); - - // Programmatically select the contents of the first text-layer span. - const selectedText = await app - .locator("#text-layer span") - .first() - .evaluate((span) => { - const range = span.ownerDocument.createRange(); - range.selectNodeContents(span); - const sel = span.ownerDocument.defaultView!.getSelection()!; - sel.removeAllRanges(); - sel.addRange(range); - return sel.toString().replace(/\s+/g, " ").trim(); - }); - expect(selectedText.length).toBeGreaterThan(0); - - await callInteract(page, { viewUUID, action: "get_viewer_state" }); - const raw = await readLastToolResult(page, 2); - const state = JSON.parse(unwrapTextResult(raw)); - - expect(state.currentPage).toBe(1); - expect(state.selection).not.toBeNull(); - expect(state.selection.text).toContain(selectedText); - expect(state.selection.boundingRect).toEqual( - expect.objectContaining({ - x: expect.any(Number), - y: expect.any(Number), - width: expect.any(Number), - height: expect.any(Number), - }), - ); - }); -}); diff --git a/tests/e2e/pdf-incremental-load.spec.ts b/tests/e2e/pdf-incremental-load.spec.ts deleted file mode 100644 index 52a99ee..0000000 --- a/tests/e2e/pdf-incremental-load.spec.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { test, expect, type Page } from "@playwright/test"; -import { - startRangeServer, - type RangeServer, -} from "../helpers/range-counting-server"; - -/** - * Regression guard for incremental PDF loading. - * - * Asserts that display_pdf does not pull the entire file before the viewer - * starts streaming, that form schema is still returned in the initial response, - * and that no byte range is fetched server-side more than once. - * - * The "noforms <30%" test is the load-bearing regression check: it fails on the - * pre-range-transport implementation (which downloads 100% during display_pdf - * for form analysis) and passes once form extraction uses range transport. - */ - -test.setTimeout(120_000); - -let rangeServer: RangeServer; - -test.beforeAll(async () => { - rangeServer = await startRangeServer(); -}); - -test.afterAll(async () => { - await rangeServer.close(); -}); - -test.beforeEach(() => { - rangeServer.resetStats(); -}); - -function getAppFrame(page: Page) { - return page.frameLocator("iframe").first().frameLocator("iframe").first(); -} - -async function waitForAppLoad(page: Page) { - const outerFrame = page.frameLocator("iframe").first(); - await expect(outerFrame.locator("iframe")).toBeVisible({ timeout: 30_000 }); -} - -/** - * Load basic-host, select PDF Server, call display_pdf with a custom URL. - * Resolves once the tool result panel appears (server-side display_pdf done); - * does NOT wait for the viewer iframe โ€” call waitForAppLoad separately so - * byte-count assertions can isolate server-side fetches from viewer fetches. - */ -async function displayPdf(page: Page, url: string) { - await page.goto("/?theme=hide"); - await expect(page.locator("select").first()).toBeEnabled({ timeout: 30_000 }); - await page.locator("select").first().selectOption({ label: "PDF Server" }); - await page.locator("textarea").fill(JSON.stringify({ url })); - await page.click('button:has-text("Call Tool")'); - await expect(page.locator('text="๐Ÿ“ค Tool Result"').first()).toBeVisible({ - timeout: 30_000, - }); -} - -/** Read and parse the most recent tool result's structuredContent. */ -async function readStructuredContent( - page: Page, -): Promise<Record<string, unknown>> { - const resultPanel = page.locator('text="๐Ÿ“ค Tool Result"').first(); - await expect(resultPanel).toBeVisible({ timeout: 30_000 }); - await resultPanel.click(); - const pre = page.locator("pre").last(); - await expect(pre).toBeVisible({ timeout: 5_000 }); - const raw = (await pre.textContent()) ?? "{}"; - const parsed = JSON.parse(raw) as { structuredContent?: object }; - return (parsed.structuredContent ?? {}) as Record<string, unknown>; -} - -async function waitForFirstPageRendered(page: Page) { - const canvas = getAppFrame(page).locator("canvas").first(); - await expect(canvas).toBeVisible({ timeout: 30_000 }); - await expect - .poll(async () => canvas.evaluate((c: HTMLCanvasElement) => c.width)) - .toBeGreaterThan(0); -} - -test.describe("PDF Server โ€” incremental loading", () => { - test("display_pdf on a form PDF returns form fields in initial response", async ({ - page, - }) => { - await displayPdf(page, `${rangeServer.baseUrl}/forms.pdf`); - await waitForAppLoad(page); - const sc = await readStructuredContent(page); - const fields = sc.formFields as Array<{ name: string }> | undefined; - expect(fields?.map((f) => f.name).sort()).toEqual([ - "city", - "email", - "name", - "notes", - "phone", - ]); - }); - - test("display_pdf on a no-forms PDF stays under byte budget and bounded overlap", async ({ - page, - }) => { - const fileSize = rangeServer.fileSizes["/noforms.pdf"]; - await displayPdf(page, `${rangeServer.baseUrl}/noforms.pdf`); - - // Measure before the viewer iframe loads so the count reflects only the - // server-side display_pdf range fetches. - expect(rangeServer.stats().totalBytesServed).toBeLessThan(fileSize * 0.3); - - await waitForAppLoad(page); - const sc = await readStructuredContent(page); - expect(sc.formFields).toBeUndefined(); - - await waitForFirstPageRendered(page); - // Server-side display_pdf and the viewer each open the document - // independently, so the xref/trailer/catalog is fetched twice (โ‰ˆ25%). - // This guards against the pre-range-transport behavior where the server - // alone pulled 100% (then 200% with the double-parse), giving overlap >> - // file size once the viewer also loaded. - expect(rangeServer.stats().overlapBytes).toBeLessThan(fileSize * 0.5); - }); - - test("first page renders under stall, then page 2 renders the >512KB image after release", async ({ - page, - }) => { - const fileSize = rangeServer.fileSizes["/noforms.pdf"]; - // Allow ~40% through (header + trailer/xref + page-1 content) then stall. - // The 1.1MB image stream referenced only by pages 2+ is the bulk. - const budget = Math.floor(fileSize * 0.4); - await displayPdf( - page, - `${rangeServer.baseUrl}/noforms.pdf?stallAfterBytes=${budget}`, - ); - await waitForAppLoad(page); - await waitForFirstPageRendered(page); - expect(rangeServer.stats().totalBytesServed).toBeLessThan(fileSize); - - rangeServer.release(); - const app = getAppFrame(page); - await app.locator("#next-btn").click(); - await expect(app.locator("#page-input")).toHaveValue("2", { - timeout: 30_000, - }); - // Page 2 references the ~1.1MB embedded JPEG. Rendering it exercises the - // viewer's range transport on a >MAX_CHUNK_BYTES object after the stall - // is released. (The server-side PdfCacheRangeTransport accumulate-once - // path is covered by the unit test in server.test.ts; on noforms.pdf - // display_pdf bails at the empty getFieldObjects() check before touching - // the image stream, so this test does not exercise it.) - await waitForFirstPageRendered(page); - expect(rangeServer.stats().totalBytesServed).toBeGreaterThan( - fileSize * 0.9, - ); - }); -}); - -// Kept in this spec because the test needs an HTTP-served PDF and the -// range-counting fixture is the convenient place; it does not use any -// byte-accounting features. -test.describe("PDF Server โ€” annotation tombstone preservation", () => { - // FIXME(https://github.com/modelcontextprotocol/ext-apps/issues/642): - // basic-host doesn't replay the cached tool result on inner-iframe reload, - // and a fresh display_pdf call gets a new toolId โ†’ new storage key, so the - // restore-from-localStorage path can't be reached. The fix itself is covered - // by the computeDiff/serializeDiff contract tests in - // src/pdf-annotations.test.ts. - test.fixme("deleted native annotation tombstone survives a persist before its page is scanned", async ({ - page, - }) => { - // Regression for the lazy baseline scan: restoredRemovedIds must be - // unioned into persistAnnotations() and getAnnotatedPdfBytes() so a - // delete on page 2 isn't silently dropped when an unrelated edit on - // page 1 triggers a persist before page 2 has been re-scanned. - - await displayPdf(page, `${rangeServer.baseUrl}/with-native-annot.pdf`); - await waitForAppLoad(page); - await waitForFirstPageRendered(page); - const sc = await readStructuredContent(page); - const viewUUID = sc.viewUUID as string; - expect(viewUUID).toBeTruthy(); - - const app = getAppFrame(page); - - // 1. Go to page 2, open the panel, delete the native annotation via UI. - await app.locator("#next-btn").click(); - await expect(app.locator("#page-input")).toHaveValue("2"); - await app.locator("#annotations-btn").click(); - const nativeCard = app.locator( - '.annotation-card[data-annotation-id^="pdf-"]', - ); - await expect(nativeCard).toBeVisible({ timeout: 10_000 }); - const nativeId = await nativeCard.getAttribute("data-annotation-id"); - expect(nativeId).toMatch(/^pdf-\d+R?$/); - await nativeCard.locator(".annotation-card-delete").click(); - // Deleting a native annotation re-renders the card as a crossed-out - // tombstone (annotation-panel.ts createRemovedAnnotationCard) with the - // same data-annotation-id โ€” it doesn't disappear from the DOM. - await expect(nativeCard).toHaveClass(/annotation-card-cleared/); - - // 2. Back to page 1 so the post-reload viewer restores there (page 2 - // must stay unscanned until the very end). - await app.locator("#page-input").fill("1"); - await app.locator("#page-input").press("Enter"); - await expect(app.locator("#page-input")).toHaveValue("1"); - - // 3. Capture the annotation localStorage key and confirm the delete was - // persisted. - const storageKey = await app - .locator("body") - .evaluate( - () => - Object.keys(localStorage).find( - (k) => k.startsWith("pdf-annot:") || k.endsWith(":annotations"), - ) ?? null, - ); - expect(storageKey).toBeTruthy(); - const diffBefore = await app - .locator("body") - .evaluate((_, k) => localStorage.getItem(k), storageKey!); - expect(JSON.parse(diffBefore!).removed).toContain(nativeId); - - // 4. Reload the inner viewer iframe ONLY (basic-host keeps the same - // cached tool result โ†’ same viewUUID/toolId โ†’ same storage key). - // restoreAnnotations() now seeds restoredRemovedIds from localStorage - // while the lazy scan has only seen page 1. - await app.locator("body").evaluate(() => location.reload()); - await waitForFirstPageRendered(page); - await expect(app.locator("#page-input")).toHaveValue("1"); - - // 5. Trigger persistAnnotations() via an unrelated edit on page 1 โ€” the - // bug scenario: page 2 has not been scanned yet. - const toolSelect = page.locator("select").nth(1); - await toolSelect.selectOption("interact"); - await page.locator("textarea").fill( - JSON.stringify({ - viewUUID, - action: "add_annotations", - annotations: [ - { - id: "probe-on-page-1", - type: "highlight", - page: 1, - rects: [{ x: 50, y: 700, width: 100, height: 12 }], - }, - ], - }), - ); - await page.click('button:has-text("Call Tool")'); - await expect( - app.locator('[data-annotation-id="probe-on-page-1"]'), - ).toHaveCount(1, { timeout: 10_000 }); - - // 6. Load-bearing assertion: the persisted diff still carries the - // tombstone. Pre-fix, computeDiff() over the page-1-only baseline - // yielded removed=[], overwriting it. - const diffAfter = await app - .locator("body") - .evaluate((_, k) => localStorage.getItem(k), storageKey!); - const removedAfter: string[] = JSON.parse(diffAfter!).removed; - expect(removedAfter).toContain(nativeId); - - // 7. Belt-and-suspenders: navigate to page 2 (lazy scan now sees the - // native annotation) and confirm the panel shows it as a cleared - // tombstone, not a live (resurrected) card. - await app.locator("#next-btn").click(); - await expect(app.locator("#page-input")).toHaveValue("2"); - await expect( - app.locator(`.annotation-card[data-annotation-id="${nativeId}"]`), - ).toHaveClass(/annotation-card-cleared/); - }); -}); diff --git a/tests/e2e/pdf-viewer-zoom.spec.ts b/tests/e2e/pdf-viewer-zoom.spec.ts deleted file mode 100644 index 574809b..0000000 --- a/tests/e2e/pdf-viewer-zoom.spec.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { test, expect, type Page } from "@playwright/test"; - -/** - * PDF Viewer zoom + fullscreen-fit tests. - * - * Covers: - * - Inline โ†’ fullscreen refits the page (the cramped inline scale is dropped) - * - Trackpad pinch (wheel + ctrlKey) zooms the page in fullscreen - * - Pinch zoom is ignored in inline mode - */ - -test.setTimeout(120000); - -function getAppFrame(page: Page) { - return page.frameLocator("iframe").first().frameLocator("iframe").first(); -} - -async function loadPdfServer(page: Page) { - await page.goto("/?theme=hide"); - await expect(page.locator("select").first()).toBeEnabled({ timeout: 30000 }); - await page.locator("select").first().selectOption({ label: "PDF Server" }); - await page.click('button:has-text("Call Tool")'); - // Wait for nested app iframe to mount - const outer = page.frameLocator("iframe").first(); - await expect(outer.locator("iframe")).toBeVisible({ timeout: 30000 }); -} - -async function waitForPdfRender(page: Page) { - const app = getAppFrame(page); - // Canvas reports a non-zero CSS width once renderPage() has sized it. - // toBeVisible alone isn't enough โ€” the canvas exists at 0ร—0 before - // first paint, so a fast test would race the render. - await expect - .poll( - async () => { - const w = await app - .locator("#pdf-canvas") - .evaluate((el: HTMLCanvasElement) => parseFloat(el.style.width)); - return w > 0 ? w : 0; - }, - { timeout: 30000 }, - ) - .toBeGreaterThan(0); -} - -/** Read the current zoom level (e.g. "65%") and return the integer percent. */ -async function readZoomPercent(page: Page): Promise<number> { - const text = await getAppFrame(page).locator("#zoom-level").textContent(); - const m = text?.match(/(\d+)%/); - if (!m) throw new Error(`Unexpected zoom-level text: ${text}`); - return parseInt(m[1], 10); -} - -test.describe("PDF Viewer โ€” fullscreen fit + pinch zoom", () => { - test("entering fullscreen drops the inline shrink-to-fit scale", async ({ - page, - }) => { - // Start narrow so the initial fit-to-width lands below 100%. - // The default arxiv PDF is ~612pt wide; a 500px iframe forces a - // fit scale around 60-70%. - await page.setViewportSize({ width: 500, height: 800 }); - await loadPdfServer(page); - await waitForPdfRender(page); - const app = getAppFrame(page); - - // Initial fit-to-width measures container.clientWidth immediately after - // showViewer() flips display:flex; under CI load the reflow can lag, so - // poll until the shrink-to-fit scale has actually applied. - await expect - .poll(() => readZoomPercent(page), { timeout: 5000 }) - .toBeLessThan(100); - const inlineZoom = await readZoomPercent(page); - - // Widen + give plenty of height, then enter fullscreen. Fullscreen uses - // fit-to-PAGE (whole page visible), so the resulting zoom is whichever - // axis is tighter. We just assert it's higher than the cramped inline - // value AND that the page actually fits without scrolling. - await page.setViewportSize({ width: 1400, height: 1000 }); - await app.locator("#fullscreen-btn").click(); - await expect(app.locator(".main.fullscreen")).toBeVisible({ - timeout: 5000, - }); - - await expect - .poll(() => readZoomPercent(page), { timeout: 5000 }) - .toBeGreaterThan(inlineZoom); - - // Whole page visible โ†’ no scroll inside the canvas-container. - const overflows = await app - .locator(".canvas-container") - .evaluate( - (el: HTMLElement) => - el.scrollHeight > el.clientHeight + 2 || - el.scrollWidth > el.clientWidth + 2, - ); - expect(overflows).toBe(false); - }); - - test("trackpad pinch (wheel + ctrlKey) zooms in fullscreen", async ({ - page, - }) => { - await page.setViewportSize({ width: 1400, height: 800 }); - await loadPdfServer(page); - await waitForPdfRender(page); - const app = getAppFrame(page); - - // Go fullscreen first โ€” pinch is fullscreen-only. - await app.locator("#fullscreen-btn").click(); - await expect(app.locator(".main.fullscreen")).toBeVisible({ - timeout: 5000, - }); - // Let the entering-fullscreen fit-to-page refit settle. - await page.waitForTimeout(500); - const before = await readZoomPercent(page); - - // Dispatch a synthetic trackpad pinch-OUT on the canvas-container. - // ctrlKey:true is what Chrome/FF/Edge/Safari emit for trackpad pinch; - // deltaY > 0 = zoom out. We pinch out so we don't hit ZOOM_MAX. - // Can't use page.mouse.wheel โ€” it doesn't expose ctrlKey. - await app.locator(".canvas-container").evaluate((el) => { - el.dispatchEvent( - new WheelEvent("wheel", { - deltaY: 50, - ctrlKey: true, - bubbles: true, - cancelable: true, - }), - ); - }); - - // The viewer applies a CSS transform live, then commits to a real - // renderPage() after a 150ms settle timer. exp(-50/100) โ‰ˆ 0.607. - await expect - .poll(() => readZoomPercent(page), { timeout: 5000 }) - .toBeLessThan(before * 0.8); - - // The CSS transform should be cleared once committed (so the canvas - // isn't double-scaled โ€” the new render IS the new scale). - await expect - .poll( - () => - app - .locator(".page-wrapper") - .evaluate((el: HTMLElement) => el.style.transform), - { timeout: 5000 }, - ) - .toBe(""); - }); - - test("trackpad pinch-in while inline enters fullscreen", async ({ page }) => { - await page.setViewportSize({ width: 1400, height: 800 }); - await loadPdfServer(page); - await waitForPdfRender(page); - const app = getAppFrame(page); - - await expect(app.locator(".main.fullscreen")).toHaveCount(0); - - await app.locator(".canvas-container").evaluate((el) => { - el.dispatchEvent( - new WheelEvent("wheel", { - deltaY: -50, - ctrlKey: true, - bubbles: true, - cancelable: true, - }), - ); - }); - - // Pinch-in should request fullscreen, not zoom the inline view. - await expect(app.locator(".main.fullscreen")).toHaveCount(1, { - timeout: 5000, - }); - }); - - test("trackpad pinch-out while inline is a no-op", async ({ page }) => { - await page.setViewportSize({ width: 1400, height: 800 }); - await loadPdfServer(page); - await waitForPdfRender(page); - const app = getAppFrame(page); - - const before = await readZoomPercent(page); - - await app.locator(".canvas-container").evaluate((el) => { - el.dispatchEvent( - new WheelEvent("wheel", { - deltaY: 50, // pinch-out - ctrlKey: true, - bubbles: true, - cancelable: true, - }), - ); - }); - - await page.waitForTimeout(300); - expect(await readZoomPercent(page)).toBe(before); - await expect(app.locator(".main.fullscreen")).toHaveCount(0); - }); -}); diff --git a/tests/e2e/security.spec.ts b/tests/e2e/security.spec.ts index 8fb94e3..f98c0b5 100644 --- a/tests/e2e/security.spec.ts +++ b/tests/e2e/security.spec.ts @@ -9,24 +9,9 @@ * Note: True cross-origin attack testing would require a multi-origin test * setup. These tests verify the security infrastructure is in place and * functioning correctly for valid communication paths. - * - * Note: These tests require the integration-server. When using EXAMPLE filter, - * set EXAMPLE=integration-server to run these tests. */ import { test, expect, type Page, type ConsoleMessage } from "@playwright/test"; -// Optional: filter to a single example via EXAMPLE env var (folder name) -// Security tests require integration-server, skip if filtering to a different example -const EXAMPLE_FILTER = process.env.EXAMPLE; -const SKIP_SECURITY_TESTS = - EXAMPLE_FILTER && EXAMPLE_FILTER !== "integration-server"; - -// Skip all security tests if filtering to a non-integration example -test.skip( - () => !!SKIP_SECURITY_TESTS, - "Skipped: security tests require integration-server", -); - /** * Capture console messages matching a pattern */ @@ -290,10 +275,8 @@ test.describe("Origin Validation Infrastructure", () => { const outerIframe = page.locator("iframe").first(); const outerSandbox = await outerIframe.getAttribute("sandbox"); - // Outer frame should NOT have allow-top-navigation (prevents navigating the host page). - // Isolation from the host comes from the separate origin (sandbox runs on port 8081 - // vs host's 8080), not from omitting allow-same-origin โ€” the outer frame has it - // (see implementation.ts:167). + // Outer frame should NOT have allow-same-origin (different origin from host) + // This ensures the sandbox cannot access host window properties expect(outerSandbox).not.toContain("allow-top-navigation"); // The app should still function despite the restrictions @@ -315,27 +298,36 @@ test.describe("Cross-App Message Injection Protection", () => { test("app rejects messages from sources other than its parent", async ({ page, }) => { - // Capture rejection logs from message-transport.ts (console.debug) - const rejectionLogs = captureConsoleLogs( - page, - /Ignoring message from unknown source/, - ); + // Capture any "unknown source" rejection logs + const rejectionLogs: string[] = []; + page.on("console", (msg) => { + const text = msg.text(); + if ( + text.includes("unknown source") || + text.includes("Ignoring message") + ) { + rejectionLogs.push(text); + } + }); await loadServer(page, "Integration Test Server"); - await expect(getAppFrame(page).locator("body")).toBeVisible(); - // window.frames[] IS cross-origin accessible per HTML spec (unlike - // contentDocument, which is null across the 8080โ†’8081 boundary โ€” see the - // test above at "sandbox cross-origin boundary prevents direct frame - // access"). This is the real attack path from the threat model comment: a - // malicious sibling app can reach - // window.parent.parent.frames[victimIdx].frames[0].postMessage(...) - // and the message WILL be delivered. PostMessageTransport's event.source - // check is the only defense. - const injected = await page.evaluate(() => { - const victim = (window.frames[0] as Window | undefined)?.frames?.[0]; - if (!victim) return "UNREACHABLE"; - victim.postMessage( + const appFrame = getAppFrame(page); + await expect(appFrame.locator("body")).toBeVisible(); + + // Try to inject a message from the page context (simulating cross-app attack) + // This simulates what would happen if another app tried to postMessage to this app + await page.evaluate(() => { + // Get reference to the inner app iframe + const outerIframe = document.querySelector("iframe"); + if (!outerIframe?.contentWindow) return; + + const innerIframe = outerIframe.contentDocument?.querySelector("iframe"); + if (!innerIframe?.contentWindow) return; + + // Try to send a fake JSON-RPC message (simulating malicious app) + // This should be rejected because event.source won't match window.parent + innerIframe.contentWindow.postMessage( { jsonrpc: "2.0", result: { content: [{ type: "text", text: "Injected!" }] }, @@ -343,19 +335,23 @@ test.describe("Cross-App Message Injection Protection", () => { }, "*", ); - return "POSTED"; }); - // Sentinel โ€” if frames[] traversal ever breaks (e.g. frame hierarchy - // changes), the test FAILS here instead of passing vacuously. The previous - // version of this test used contentDocument?.querySelector() which - // silently short-circuited to undefined and never posted anything. - expect(injected).toBe("POSTED"); + // Wait for message to be processed + await page.waitForTimeout(500); - // Assert PostMessageTransport rejected it (event.source !== window.parent) - await expect - .poll(() => rejectionLogs.length, { timeout: 2000 }) - .toBeGreaterThan(0); + // The injected message should have been rejected + // (it won't cause visible harm even if not logged, but ideally we see rejection) + // The app should still be functional (not corrupted by the injection) + await expect(appFrame.locator("body")).toBeVisible(); + + // Verify legitimate communication still works after attempted injection + const sendMessageBtn = appFrame.locator('button:has-text("Send Message")'); + if (await sendMessageBtn.isVisible()) { + await sendMessageBtn.click(); + await page.waitForTimeout(300); + // If we get here without errors, the app wasn't corrupted + } }); test("PostMessageTransport is configured with source validation", async ({ diff --git a/tests/e2e/servers.spec.ts b/tests/e2e/servers.spec.ts index a3c30d8..29b251e 100644 --- a/tests/e2e/servers.spec.ts +++ b/tests/e2e/servers.spec.ts @@ -7,18 +7,15 @@ import { test, expect, type Page, type ConsoleMessage } from "@playwright/test"; // // Note: map-server uses SLOW_SERVERS timeout instead of masking to wait for tiles const DYNAMIC_MASKS: Record<string, string[]> = { - integration: ['[class*="serverTime"]'], // Server time display [CSS module] - "basic-preact": ['[class*="serverTime"]'], // Server time display [CSS module] - "basic-react": ['[class*="serverTime"]'], // Server time display [CSS module] - "basic-solid": ['[class*="serverTime"]'], // Server time display [CSS module] - "basic-svelte": [".server-time"], // Server time display (component-scoped) + integration: ["#server-time"], // Server time display + "basic-preact": ["#server-time"], // Server time display + "basic-react": ["#server-time"], // Server time display + "basic-solid": ["#server-time"], // Server time display + "basic-svelte": ["#server-time"], // Server time display "basic-vanillajs": ["#server-time"], // Server time display - "basic-vue": [".server-time"], // Server time display (scoped styles) - "cohort-heatmap": ['[class*="heatmapWrapper"]'], // Heatmap grid (random data) [CSS module] + "basic-vue": ["#server-time"], // Server time display + "cohort-heatmap": ['[class*="heatmapWrapper"]'], // Heatmap grid (random data) "customer-segmentation": [".chart-container"], // Scatter plot (random data) - "debug-server": ["#event-log", "#callback-table-body"], // Event log and callback counts (dynamic) - quickstart: ["#server-time"], // Server time display - "say-server": [".playBtn", ".playOverlayBtn"], // Play buttons may have different states shadertoy: ["#canvas"], // WebGL shader canvas (animated) "system-monitor": [ ".chart-container", // CPU chart (highly dynamic) @@ -34,120 +31,34 @@ const DYNAMIC_MASKS: Record<string, string[]> = { // Servers that need extra stabilization time (e.g., for tile loading, WebGL init) const SLOW_SERVERS: Record<string, number> = { - "map-server": 15000, // CesiumJS needs time for tiles to load + "map-server": 5000, // CesiumJS needs time for tiles to load threejs: 2000, // Three.js WebGL initialization - "say-server": 10000, // TTS model download from HuggingFace can be slow }; -// Host-level masks (outside app iframe) - for dynamic content in Tool Input/Result panels -// Use [class*="..."] for CSS modules which generate unique class names -const HOST_MASKS: Record<string, string[]> = { - // Servers with dynamic timestamps in Tool Result (get-time response) - // Mask entire collapsible panels to avoid font rendering differences - integration: ['[class*="collapsiblePanel"]'], - "basic-preact": ['[class*="collapsiblePanel"]'], - "basic-react": ['[class*="collapsiblePanel"]'], - "basic-solid": ['[class*="collapsiblePanel"]'], - "basic-svelte": ['[class*="collapsiblePanel"]'], - "basic-vanillajs": ['[class*="collapsiblePanel"]'], - "basic-vue": ['[class*="collapsiblePanel"]'], - // System monitor has dynamic system stats in result - "system-monitor": ['[class*="collapsiblePanel"]'], -}; - -// Servers to skip in CI (require special resources like GPU, large ML models) -const SKIP_SERVERS = new Set<string>([ - "qr-server", // TODO - "say-server", // TTS model download from HuggingFace can be slow -]); - -// Optional: filter to a single example via EXAMPLE env var (folder name) -const EXAMPLE_FILTER = process.env.EXAMPLE; - -// Server configurations (key is used for screenshot filenames, name is the MCP server name, dir is the folder name) -const ALL_SERVERS = [ - { - key: "integration", - name: "Integration Test Server", - dir: "integration-server", - }, - { - key: "basic-preact", - name: "Basic MCP App Server (Preact)", - dir: "basic-server-preact", - }, - { - key: "basic-react", - name: "Basic MCP App Server (React)", - dir: "basic-server-react", - }, - { - key: "basic-solid", - name: "Basic MCP App Server (Solid)", - dir: "basic-server-solid", - }, - { - key: "basic-svelte", - name: "Basic MCP App Server (Svelte)", - dir: "basic-server-svelte", - }, - { - key: "basic-vanillajs", - name: "Basic MCP App Server (Vanilla JS)", - dir: "basic-server-vanillajs", - }, - { - key: "basic-vue", - name: "Basic MCP App Server (Vue)", - dir: "basic-server-vue", - }, - { - key: "budget-allocator", - name: "Budget Allocator Server", - dir: "budget-allocator-server", - }, - { - key: "cohort-heatmap", - name: "Cohort Heatmap Server", - dir: "cohort-heatmap-server", - }, - { - key: "customer-segmentation", - name: "Customer Segmentation Server", - dir: "customer-segmentation-server", - }, - { key: "debug-server", name: "Debug MCP App Server", dir: "debug-server" }, - { key: "map-server", name: "CesiumJS Map Server", dir: "map-server" }, - { key: "pdf-server", name: "PDF Server", dir: "pdf-server" }, - { key: "qr-server", name: "QR Code Server", dir: "qr-server" }, - { - key: "quickstart", - name: "Quickstart MCP App Server", - dir: "quickstart", - }, - { key: "say-server", name: "Say Demo", dir: "say-server" }, - { - key: "scenario-modeler", - name: "SaaS Scenario Modeler", - dir: "scenario-modeler-server", - }, - { key: "shadertoy", name: "ShaderToy Server", dir: "shadertoy-server" }, - { key: "sheet-music", name: "Sheet Music Server", dir: "sheet-music-server" }, - { - key: "system-monitor", - name: "System Monitor Server", - dir: "system-monitor-server", - }, - { key: "threejs", name: "Three.js Server", dir: "threejs-server" }, - { key: "transcript", name: "Transcript Server", dir: "transcript-server" }, - { key: "wiki-explorer", name: "Wiki Explorer", dir: "wiki-explorer-server" }, +// Server configurations (key is used for screenshot filenames, name is the MCP server name) +const SERVERS = [ + { key: "integration", name: "Integration Test Server" }, + { key: "basic-preact", name: "Basic MCP App Server (Preact)" }, + { key: "basic-react", name: "Basic MCP App Server (React)" }, + { key: "basic-solid", name: "Basic MCP App Server (Solid)" }, + { key: "basic-svelte", name: "Basic MCP App Server (Svelte)" }, + { key: "basic-vanillajs", name: "Basic MCP App Server (Vanilla JS)" }, + { key: "basic-vue", name: "Basic MCP App Server (Vue)" }, + { key: "budget-allocator", name: "Budget Allocator Server" }, + { key: "cohort-heatmap", name: "Cohort Heatmap Server" }, + { key: "customer-segmentation", name: "Customer Segmentation Server" }, + { key: "map-server", name: "Map Server" }, + { key: "pdf-server", name: "PDF Server" }, + { key: "qr-server", name: "QR Code Server" }, + { key: "scenario-modeler", name: "SaaS Scenario Modeler" }, + { key: "shadertoy", name: "ShaderToy Server" }, + { key: "sheet-music", name: "Sheet Music Server" }, + { key: "system-monitor", name: "System Monitor Server" }, + { key: "threejs", name: "Three.js Server" }, + { key: "transcript", name: "Transcript Server" }, + { key: "wiki-explorer", name: "Wiki Explorer" }, ]; -// Filter servers if EXAMPLE is specified -const SERVERS = EXAMPLE_FILTER - ? ALL_SERVERS.filter((s) => s.dir === EXAMPLE_FILTER) - : ALL_SERVERS; - /** * Helper to get the app frame locator (nested: sandbox > app) */ @@ -175,15 +86,14 @@ function captureHostLogs(page: Page): string[] { */ async function waitForAppLoad(page: Page) { const outerFrame = page.frameLocator("iframe").first(); - await expect(outerFrame.locator("iframe")).toBeVisible({ timeout: 30000 }); + await expect(outerFrame.locator("iframe")).toBeVisible(); } /** - * Load a server by selecting it by name and clicking Call Tool. - * Uses ?theme=hide to hide the theme toggle for consistent screenshots. + * Load a server by selecting it by name and clicking Call Tool */ async function loadServer(page: Page, serverName: string) { - await page.goto("/?theme=hide"); + await page.goto("/"); // Wait for servers to connect (select becomes enabled when servers are ready) await expect(page.locator("select").first()).toBeEnabled({ timeout: 30000 }); await page.locator("select").first().selectOption({ label: serverName }); @@ -192,37 +102,26 @@ async function loadServer(page: Page, serverName: string) { } /** - * Get mask locators for dynamic elements (both in app iframe and host). + * Get mask locators for dynamic elements inside the nested app iframe. */ function getMaskLocators(page: Page, serverKey: string) { - const masks = []; + const selectors = DYNAMIC_MASKS[serverKey]; + if (!selectors) return []; - // App-level masks (inside nested iframe) - const appSelectors = DYNAMIC_MASKS[serverKey]; - if (appSelectors) { - const appFrame = getAppFrame(page); - masks.push(...appSelectors.map((selector) => appFrame.locator(selector))); - } - - // Host-level masks (Tool Input/Result panels with dynamic content) - const hostSelectors = HOST_MASKS[serverKey]; - if (hostSelectors) { - masks.push(...hostSelectors.map((selector) => page.locator(selector))); - } - - return masks; + const appFrame = getAppFrame(page); + return selectors.map((selector) => appFrame.locator(selector)); } test.describe("Host UI", () => { test("initial state shows controls", async ({ page }) => { - await page.goto("/?theme=hide"); + await page.goto("/"); await expect(page.locator("label:has-text('Server')")).toBeVisible(); await expect(page.locator("label:has-text('Tool')")).toBeVisible(); await expect(page.locator('button:has-text("Call Tool")')).toBeVisible(); }); test("screenshot of initial state", async ({ page }) => { - await page.goto("/?theme=hide"); + await page.goto("/"); await expect(page.locator('button:has-text("Call Tool")')).toBeVisible(); await expect(page).toHaveScreenshot("host-initial.png"); }); @@ -230,23 +129,12 @@ test.describe("Host UI", () => { // Define tests for each server using forEach to avoid for-loop issues SERVERS.forEach((server) => { - // Skip servers that require special resources (GPU, large ML models) - const shouldSkip = SKIP_SERVERS.has(server.key); - test.describe(server.name, () => { test("loads app UI", async ({ page }) => { - if (shouldSkip) { - test.skip(); - return; - } await loadServer(page, server.name); }); test("screenshot matches golden", async ({ page }) => { - if (shouldSkip) { - test.skip(); - return; - } await loadServer(page, server.name); // Some servers (WebGL, tile-based) need extra stabilization time @@ -265,20 +153,12 @@ SERVERS.forEach((server) => { }); // Interaction tests for integration server (tests all SDK communication APIs) -// Only run if integration-server is included (either no filter or EXAMPLE=integration-server) -const integrationServer = SERVERS.find((s) => s.key === "integration"); -const integrationServerName = - integrationServer?.name ?? "Integration Test Server"; - -test.describe(`Integration Test Server - Interactions`, () => { - test.skip( - () => !integrationServer, - "Skipped: integration-server not in EXAMPLE filter", - ); +const integrationServer = SERVERS.find((s) => s.key === "integration")!; +test.describe(`${integrationServer.name} - Interactions`, () => { test("Send Message button triggers host callback", async ({ page }) => { const logs = captureHostLogs(page); - await loadServer(page, integrationServerName); + await loadServer(page, integrationServer.name); const appFrame = getAppFrame(page); await appFrame.locator('button:has-text("Send Message")').click(); @@ -291,7 +171,7 @@ test.describe(`Integration Test Server - Interactions`, () => { test("Send Log button triggers host callback", async ({ page }) => { const logs = captureHostLogs(page); - await loadServer(page, integrationServerName); + await loadServer(page, integrationServer.name); const appFrame = getAppFrame(page); await appFrame.locator('button:has-text("Send Log")').click(); @@ -305,7 +185,7 @@ test.describe(`Integration Test Server - Interactions`, () => { test("Open Link button triggers host callback", async ({ page }) => { const logs = captureHostLogs(page); - await loadServer(page, integrationServerName); + await loadServer(page, integrationServer.name); const appFrame = getAppFrame(page); await appFrame.locator('button:has-text("Open Link")').click(); diff --git a/tests/e2e/servers.spec.ts-snapshots/basic-preact.png b/tests/e2e/servers.spec.ts-snapshots/basic-preact.png index 49e4915..96d22f0 100644 Binary files a/tests/e2e/servers.spec.ts-snapshots/basic-preact.png and b/tests/e2e/servers.spec.ts-snapshots/basic-preact.png differ diff --git a/tests/e2e/servers.spec.ts-snapshots/basic-react.png b/tests/e2e/servers.spec.ts-snapshots/basic-react.png index 5c745ac..6aaf42c 100644 Binary files a/tests/e2e/servers.spec.ts-snapshots/basic-react.png and b/tests/e2e/servers.spec.ts-snapshots/basic-react.png differ diff --git a/tests/e2e/servers.spec.ts-snapshots/basic-solid.png b/tests/e2e/servers.spec.ts-snapshots/basic-solid.png index a1244eb..5b5b812 100644 Binary files a/tests/e2e/servers.spec.ts-snapshots/basic-solid.png and b/tests/e2e/servers.spec.ts-snapshots/basic-solid.png differ diff --git a/tests/e2e/servers.spec.ts-snapshots/basic-svelte.png b/tests/e2e/servers.spec.ts-snapshots/basic-svelte.png index 0155dc0..b2a95f5 100644 Binary files a/tests/e2e/servers.spec.ts-snapshots/basic-svelte.png and b/tests/e2e/servers.spec.ts-snapshots/basic-svelte.png differ diff --git a/tests/e2e/servers.spec.ts-snapshots/basic-vanillajs.png b/tests/e2e/servers.spec.ts-snapshots/basic-vanillajs.png index 7d8ba2c..d6e8771 100644 Binary files a/tests/e2e/servers.spec.ts-snapshots/basic-vanillajs.png and b/tests/e2e/servers.spec.ts-snapshots/basic-vanillajs.png differ diff --git a/tests/e2e/servers.spec.ts-snapshots/basic-vue.png b/tests/e2e/servers.spec.ts-snapshots/basic-vue.png index f441f5d..b4cb257 100644 Binary files a/tests/e2e/servers.spec.ts-snapshots/basic-vue.png and b/tests/e2e/servers.spec.ts-snapshots/basic-vue.png differ diff --git a/tests/e2e/servers.spec.ts-snapshots/cohort-heatmap.png b/tests/e2e/servers.spec.ts-snapshots/cohort-heatmap.png index fe4bfb1..fbb33fb 100644 Binary files a/tests/e2e/servers.spec.ts-snapshots/cohort-heatmap.png and b/tests/e2e/servers.spec.ts-snapshots/cohort-heatmap.png differ diff --git a/tests/e2e/servers.spec.ts-snapshots/debug-server.png b/tests/e2e/servers.spec.ts-snapshots/debug-server.png deleted file mode 100644 index 2b902e3..0000000 Binary files a/tests/e2e/servers.spec.ts-snapshots/debug-server.png and /dev/null differ diff --git a/tests/e2e/servers.spec.ts-snapshots/integration.png b/tests/e2e/servers.spec.ts-snapshots/integration.png index 5fb7b0b..c735040 100644 Binary files a/tests/e2e/servers.spec.ts-snapshots/integration.png and b/tests/e2e/servers.spec.ts-snapshots/integration.png differ diff --git a/tests/e2e/servers.spec.ts-snapshots/map-server.png b/tests/e2e/servers.spec.ts-snapshots/map-server.png index b1c77e3..373ae71 100644 Binary files a/tests/e2e/servers.spec.ts-snapshots/map-server.png and b/tests/e2e/servers.spec.ts-snapshots/map-server.png differ diff --git a/tests/e2e/servers.spec.ts-snapshots/quickstart.png b/tests/e2e/servers.spec.ts-snapshots/quickstart.png deleted file mode 100644 index 18ad40e..0000000 Binary files a/tests/e2e/servers.spec.ts-snapshots/quickstart.png and /dev/null differ diff --git a/tests/e2e/servers.spec.ts-snapshots/say-server.png b/tests/e2e/servers.spec.ts-snapshots/say-server.png deleted file mode 100644 index 6bd2215..0000000 Binary files a/tests/e2e/servers.spec.ts-snapshots/say-server.png and /dev/null differ diff --git a/tests/e2e/servers.spec.ts-snapshots/shadertoy.png b/tests/e2e/servers.spec.ts-snapshots/shadertoy.png index f34aa95..0347fdd 100644 Binary files a/tests/e2e/servers.spec.ts-snapshots/shadertoy.png and b/tests/e2e/servers.spec.ts-snapshots/shadertoy.png differ diff --git a/tests/e2e/servers.spec.ts-snapshots/sheet-music.png b/tests/e2e/servers.spec.ts-snapshots/sheet-music.png index f34cb5e..34d1552 100644 Binary files a/tests/e2e/servers.spec.ts-snapshots/sheet-music.png and b/tests/e2e/servers.spec.ts-snapshots/sheet-music.png differ diff --git a/tests/e2e/servers.spec.ts-snapshots/system-monitor.png b/tests/e2e/servers.spec.ts-snapshots/system-monitor.png index df583de..4d6ef11 100644 Binary files a/tests/e2e/servers.spec.ts-snapshots/system-monitor.png and b/tests/e2e/servers.spec.ts-snapshots/system-monitor.png differ diff --git a/tests/e2e/servers.spec.ts-snapshots/wiki-explorer.png b/tests/e2e/servers.spec.ts-snapshots/wiki-explorer.png index eeae395..462d795 100644 Binary files a/tests/e2e/servers.spec.ts-snapshots/wiki-explorer.png and b/tests/e2e/servers.spec.ts-snapshots/wiki-explorer.png differ diff --git a/tests/helpers/assets/fw9.pdf b/tests/helpers/assets/fw9.pdf deleted file mode 100644 index c5eb646..0000000 Binary files a/tests/helpers/assets/fw9.pdf and /dev/null differ diff --git a/tests/helpers/range-counting-server.ts b/tests/helpers/range-counting-server.ts deleted file mode 100644 index 6f0dcd7..0000000 --- a/tests/helpers/range-counting-server.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * HTTP test fixture serving programmatically-generated PDFs with byte-range - * accounting. Used by pdf-incremental-load.spec.ts to assert that display_pdf - * doesn't pull the whole file before the viewer starts streaming. - * - * Plain HTTP on loopback โ€” playwright.config.ts sets - * PDF_SERVER_ALLOW_LOOPBACK_HTTP=1 so validateUrl accepts http://127.0.0.1. - */ -import http from "node:http"; -import type { AddressInfo } from "node:net"; -import { - PDFDocument, - PDFName, - PDFString, - StandardFonts, -} from "@cantoo/pdf-lib"; - -export interface RangeServerStats { - /** Total bytes written across all responses (sum of slice lengths). */ - totalBytesServed: number; - /** Bytes that were served more than once for the same path. */ - overlapBytes: number; -} - -export interface RangeServer { - port: number; - baseUrl: string; - /** Map of served path โ†’ byte length. */ - fileSizes: Record<string, number>; - stats(): RangeServerStats; - resetStats(): void; - /** Resolve any requests currently stalled by ?stallAfterBytes=N. */ - release(): void; - close(): Promise<void>; -} - -const LOREM = - "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod " + - "tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim " + - "veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea " + - "commodo consequat. "; - -async function buildNoFormsPdf(): Promise<Uint8Array> { - const doc = await PDFDocument.create(); - const font = await doc.embedFont(StandardFonts.Helvetica); - // Page 1 is text-only (small) so first paint needs minimal bytes. Pages 2+ - // each reference a large embedded JPEG so the bulk of the file is in image - // streams page 1 doesn't need. The stallAfterBytes test holds those back - // and asserts page 1 still renders. The image is >MAX_CHUNK_BYTES (512KB) - // so rendering page 2 also exercises the viewer's >512KB range path. - const big = await doc.embedJpg(makeRandomJpeg(1_100 * 1024)); - const page1 = doc.addPage([612, 792]); - for (let line = 0; line < 30; line++) { - page1.drawText(`1.${line + 1} ${LOREM}`, { - x: 36, - y: 760 - line * 22, - size: 10, - font, - }); - } - for (let p = 1; p < 20; p++) { - const page = doc.addPage([612, 792]); - page.drawImage(big, { x: 36, y: 200, width: 540, height: 540 }); - page.drawText(`Page ${p + 1}`, { x: 36, y: 760, size: 10, font }); - } - return doc.save(); -} - -/** Minimal valid JPEG with `len` bytes of incompressible scan data. */ -export function makeRandomJpeg(len: number): Uint8Array { - // SOI, APP0 (JFIF), SOF0 (baseline 8x8 1-component), DHT (minimal), - // SOS, <random scan data>, EOI. pdf-lib only needs to parse the headers - // to embed; the scan data is opaque. - const header = Uint8Array.from([ - 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, - 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xc0, 0x00, 0x0b, - 0x08, 0x00, 0x08, 0x00, 0x08, 0x01, 0x01, 0x11, 0x00, 0xff, 0xc4, 0x00, - 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xda, 0x00, 0x08, 0x01, - 0x01, 0x00, 0x00, 0x3f, 0x00, - ]); - const scan = new Uint8Array(len); - for (let i = 0; i < len; i++) scan[i] = (i * 1103515245 + 12345) & 0xff; - // Avoid 0xFF in scan data so we don't accidentally form a marker. - for (let i = 0; i < len; i++) if (scan[i] === 0xff) scan[i] = 0xfe; - const eoi = Uint8Array.from([0xff, 0xd9]); - const out = new Uint8Array(header.length + scan.length + eoi.length); - out.set(header, 0); - out.set(scan, header.length); - out.set(eoi, header.length + scan.length); - return out; -} - -/** - * Two pages, page 1 text-only, page 2 carries one native /Text (sticky-note) - * annotation. Used by the tombstone-preservation e2e: the viewer's lazy - * baseline scan must not have visited page 2 when persistAnnotations runs. - */ -async function buildWithNativeAnnotPdf(): Promise<Uint8Array> { - const doc = await PDFDocument.create(); - const font = await doc.embedFont(StandardFonts.Helvetica); - const page1 = doc.addPage([612, 792]); - page1.drawText("Page 1 โ€” no native annotations here.", { - x: 36, - y: 740, - size: 12, - font, - }); - const page2 = doc.addPage([612, 792]); - page2.drawText("Page 2 โ€” has one native /Text annot.", { - x: 36, - y: 740, - size: 12, - font, - }); - const annotRef = doc.context.register( - doc.context.obj({ - Type: "Annot", - Subtype: "Text", - Rect: [100, 700, 120, 720], - Contents: PDFString.of("native sticky note"), - Open: false, - Name: "Comment", - }), - ); - page2.node.set(PDFName.of("Annots"), doc.context.obj([annotRef])); - return doc.save(); -} - -async function buildFormsPdf(): Promise<Uint8Array> { - const doc = await PDFDocument.create(); - const form = doc.getForm(); - for (let p = 0; p < 2; p++) doc.addPage([612, 792]); - const [page1] = doc.getPages(); - const fields = ["name", "email", "phone", "city", "notes"]; - fields.forEach((name, i) => { - const f = form.createTextField(name); - f.addToPage(page1, { x: 100, y: 650 - i * 60, width: 300, height: 24 }); - }); - return doc.save(); -} - -export async function startRangeServer(): Promise<RangeServer> { - if (process.env.NODE_ENV === "production") { - throw new Error( - "range-counting-server is a test fixture; refusing to start with NODE_ENV=production", - ); - } - const files: Record<string, Uint8Array> = { - "/noforms.pdf": await buildNoFormsPdf(), - "/forms.pdf": await buildFormsPdf(), - "/with-native-annot.pdf": await buildWithNativeAnnotPdf(), - }; - const fileSizes = Object.fromEntries( - Object.entries(files).map(([k, v]) => [k, v.length]), - ); - - // Per-path hit count per byte, for overlap accounting. - const hitCounts: Record<string, Uint8Array> = {}; - const initHits = () => { - for (const [k, v] of Object.entries(files)) { - hitCounts[k] = new Uint8Array(v.length); - } - }; - initHits(); - - let totalBytesServed = 0; - let releaseResolve: (() => void) | undefined; - let releasePromise = new Promise<void>((r) => (releaseResolve = r)); - - const server = http.createServer(async (req, res) => { - const url = new URL(req.url ?? "/", "http://127.0.0.1"); - const body = files[url.pathname]; - if (!body) { - res.writeHead(404).end(); - return; - } - - const stallAfterBytes = url.searchParams.get("stallAfterBytes"); - const total = body.length; - const range = req.headers.range; - - let begin = 0; - let end = total; // exclusive - let status = 200; - if (range) { - const m = /^bytes=(\d+)-(\d*)$/.exec(range); - if (m) { - begin = parseInt(m[1], 10); - end = m[2] ? parseInt(m[2], 10) + 1 : total; - begin = Math.min(begin, total); - end = Math.min(end, total); - status = 206; - } - } - - // Stall once N bytes have already been served โ€” lets pdfjs read the - // header/trailer/xref (scattered across the file) before blocking the - // bulk content streams. - if (stallAfterBytes !== null) { - if (totalBytesServed >= parseInt(stallAfterBytes, 10)) { - await releasePromise; - } - } - - const slice = body.subarray(begin, end); - totalBytesServed += slice.length; - const hits = hitCounts[url.pathname]; - for (let i = begin; i < end; i++) hits[i]++; - - const headers: Record<string, string> = { - "Content-Type": "application/pdf", - "Accept-Ranges": "bytes", - "Content-Length": String(slice.length), - }; - if (status === 206) { - headers["Content-Range"] = `bytes ${begin}-${end - 1}/${total}`; - } - res.writeHead(status, headers); - res.end(slice); - }); - - await new Promise<void>((resolve) => server.listen(0, resolve)); - const port = (server.address() as AddressInfo).port; - - return { - port, - baseUrl: `http://127.0.0.1:${port}`, - fileSizes, - stats() { - let overlapBytes = 0; - for (const hits of Object.values(hitCounts)) { - for (let i = 0; i < hits.length; i++) if (hits[i] > 1) overlapBytes++; - } - return { totalBytesServed, overlapBytes }; - }, - resetStats() { - totalBytesServed = 0; - initHits(); - // Unblock any handlers parked on the previous stall before re-arming, - // otherwise they hold sockets open forever and close() hangs. - releaseResolve?.(); - releasePromise = new Promise<void>((r) => (releaseResolve = r)); - }, - release() { - releaseResolve?.(); - }, - close() { - releaseResolve?.(); - server.closeAllConnections?.(); - return new Promise<void>((resolve) => server.close(() => resolve())); - }, - }; -} diff --git a/tsconfig.json b/tsconfig.json index 934b950..85ec789 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "types": ["node", "bun"], "target": "ES2020", "module": "ESNext", "lib": ["ES2020", "DOM"], @@ -16,6 +15,6 @@ "skipLibCheck": true, "resolveJsonModule": true }, - "include": ["src/**/*.ts", "src/**/*.tsx", "docs/**/*.ts", "docs/**/*.tsx"], + "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": ["node_modules", "dist", "examples/**/*.ts"] } diff --git a/typedoc.config.mjs b/typedoc.config.mjs index c9b9476..bda329e 100644 --- a/typedoc.config.mjs +++ b/typedoc.config.mjs @@ -1,26 +1,10 @@ import { OptionDefaults } from "typedoc"; -const BASE_SDK_DOCS = "https://ts.sdk.modelcontextprotocol.io/v2/"; - /** @type {Partial<import('typedoc').TypeDocOptions>} */ const config = { - name: "MCP Apps", - readme: "README.md", - headings: { - readme: false, - }, - gitRevision: "main", - projectDocuments: [ - "docs/overview.md", - "docs/quickstart.md", - "docs/agent-skills.md", - "docs/testing-mcp-apps.md", - "docs/patterns.md", - "docs/authorization.md", - "docs/csp-cors.md", - "docs/migrate_from_openai_apps.md", - "docs/migrate-to-2.md", - ], + blockTags: [...OptionDefaults.blockTags, "@description"], + intentionallyNotExported: ["AppOptions"], + projectDocuments: ["docs/quickstart.md"], entryPoints: [ "src/server/index.ts", "src/app.ts", @@ -29,67 +13,18 @@ const config = { "src/message-transport.ts", "src/types.ts", ], + out: "docs/api", + gitRevision: "main", excludePrivate: true, excludeInternal: false, - intentionallyNotExported: ["RequestHandlerExtra"], - externalSymbolLinkMappings: { - "@modelcontextprotocol/client": { - ClientOptions: BASE_SDK_DOCS, - ConnectOptions: BASE_SDK_DOCS, - DiscoverResult: BASE_SDK_DOCS, - "MessageExtraInfo.classification": BASE_SDK_DOCS, - ProtocolError: BASE_SDK_DOCS, - "Protocol.close": BASE_SDK_DOCS, - "Protocol.notification": BASE_SDK_DOCS, - ResponseCacheStore: BASE_SDK_DOCS, - SdkError: BASE_SDK_DOCS, - "SdkErrorCode.MethodNotSupportedByProtocolVersion": BASE_SDK_DOCS, - StandardJSONSchemaV1: BASE_SDK_DOCS, - "SdkErrorCode.UnsupportedResultType": BASE_SDK_DOCS, - "__type.enforceStrictCapabilities": BASE_SDK_DOCS, - }, - "@modelcontextprotocol/server": { - LoggingMessageNotification: BASE_SDK_DOCS, - "McpServer.registerTool": BASE_SDK_DOCS, - "MessageExtraInfo.classification": BASE_SDK_DOCS, - "Protocol.close": BASE_SDK_DOCS, - "Protocol.notification": BASE_SDK_DOCS, - "SdkErrorCode.UnsupportedResultType": BASE_SDK_DOCS, - }, - }, - blockTags: [...OptionDefaults.blockTags, "@description"], - jsDocCompatibility: { - exampleTag: false, - }, - includeVersion: false, categorizeByGroup: true, - groupOrder: ["Getting Started", "Security", "Modules", "*"], - navigation: { - includeGroups: true, - }, navigationLinks: { GitHub: "https://github.com/modelcontextprotocol/ext-apps", Specification: - "https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx", - }, - hostedBaseUrl: "https://apps.extensions.modelcontextprotocol.io/api/", - customCss: "./docs/mcp-theme.css", - out: "docs/api", - plugin: [ - "typedoc-github-theme", - "./scripts/typedoc-plugin-base-sdk-links.mjs", - "./scripts/typedoc-plugin-fix-mermaid-entities.mjs", - "./scripts/typedoc-plugin-seo.mjs", - "./scripts/typedoc-plugin-mcpstyle.mjs", - "@boneskull/typedoc-plugin-mermaid", - ], - ignoredHighlightLanguages: ["mermaid"], - locales: { - en: { - kind_plural_document: "Getting Started", - kind_plural_module: "API Documentation", - }, + "https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx", }, + readme: "README.md", + includeVersion: true, }; export default config;