diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6848e4c..c444585 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 @@ -101,62 +66,31 @@ jobs: - 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 +103,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 +113,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 +146,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 index 182f01a..52795d6 100644 --- a/.github/workflows/update-snapshots.yml +++ b/.github/workflows/update-snapshots.yml @@ -16,20 +16,16 @@ permissions: 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. + # Run on workflow_dispatch OR when someone comments "/update-snapshots" on a PR 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)) + (github.event.issue.pull_request && contains(github.event.comment.body, '/update-snapshots')) runs-on: ubuntu-latest steps: - name: Get PR branch if: github.event_name == 'issue_comment' id: pr - uses: actions/github-script@v8 + uses: actions/github-script@v7 with: script: | const pr = await github.rest.pulls.get({ @@ -42,7 +38,7 @@ jobs: - name: Add reaction to comment if: github.event_name == 'issue_comment' - uses: actions/github-script@v8 + uses: actions/github-script@v7 with: script: | await github.rest.reactions.createForIssueComment({ @@ -52,7 +48,7 @@ jobs: content: 'rocket' }); - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 with: ref: ${{ github.event.inputs.branch || steps.pr.outputs.ref || github.ref }} token: ${{ secrets.GITHUB_TOKEN }} @@ -61,12 +57,11 @@ jobs: 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 @@ -93,7 +88,7 @@ jobs: - name: Comment on PR if: github.event_name == 'issue_comment' - uses: actions/github-script@v8 + uses: actions/github-script@v7 with: script: | const changed = '${{ steps.commit.outputs.changed }}' === 'true'; diff --git a/.gitignore b/.gitignore index 5b5486b..9c6a137 100644 --- a/.gitignore +++ b/.gitignore @@ -12,9 +12,4 @@ intermediate-findings/ playwright-report/ test-results/ __pycache__/ -*.pyc - -# MCPB bundles (built artifacts) -*.mcpb -.mcpb-stage/ -.mcpb-smoke/ \ No newline at end of file +*.pyc \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit index ae5b3c7..6fd6718 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -14,16 +14,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..f5edcf9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,6 +4,4 @@ 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..9bbd2f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Key abstractions: - **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,12 +67,12 @@ rm -fR package-lock.json node_modules && \ ### Protocol Flow ``` -View (App/Protocol) <--PostMessageTransport--> Host (AppBridge/Protocol) <--separate MCP Client--> MCP Server +View (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 +3. View 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()` 6. Host sends `sendToolResult()` when tool execution completes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e28dcd1..3793622 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 @@ -502,7 +91,7 @@ Alternatively, use the [workflow dispatch](https://github.com/modelcontextprotoc ## 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 +128,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 +185,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..0546820 100644 --- a/README.md +++ b/README.md @@ -1,185 +1,67 @@ - -
- - - - 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 +MCP Apps are a proposed standard inspired by [MCP-UI](https://mcpui.dev/) and [OpenAI's Apps SDK](https://developers.openai.com/apps-sdk/) to allow MCP Servers to display interactive UI elements in conversational MCP clients / chatbots. -- [Build with Agent Skills](#build-with-agent-skills) -- [Supported Clients](#supported-clients) -- [Why MCP Apps?](#why-mcp-apps) -- [How It Works](#how-it-works) -- [Getting Started](#getting-started) -- [Using the SDK](#using-the-sdk) -- [Examples](#examples) -- [Specification](#specification) -- [Resources](#resources) -- [Contributing](#contributing) +## Why MCP Apps? -## Build with Agent Skills +MCP tools return text and structured data. That works for many cases, but not when you need an interactive UI, like a chart, form, or video player. -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: +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. -| 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"_ | +## How It Works -### Install the Skills +MCP Apps extend the Model Context Protocol by letting tools declare UI resources: -**Claude Code** โ€” install via the plugin marketplace: +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 + +## Using the SDK + +This SDK serves two audiences: + +### For 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) + +### For 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 +``` + +### Install Agent Skills + +This repository provides two [Agent Skills](https://agentskills.io/) for building MCP Apps. You can install the skills as a Claude Code plugin: ``` /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. - -## How It Works - -MCP Apps extend the Model Context Protocol by letting tools declare UI -resources: - -1. **Tool definition** โ€” Your tool declares a `ui://` resource containing its - HTML interface -2. **Tool call** โ€” The LLM calls the tool on your server -3. **Host renders** โ€” The host fetches the resource and displays it in a - sandboxed iframe -4. **Bidirectional communication** โ€” The host passes tool data to the UI via - notifications, and the UI can call other tools through the host - -## Getting Started - -Requires Node.js 20+. The base MCP SDK packages are `^2.0.0` peers of -`ext-apps` (`@modelcontextprotocol/core` is a required peer that `client` already depends on, so npm installs it for you). - -For a View or host: - -```bash -npm install -S @modelcontextprotocol/ext-apps \ - @modelcontextprotocol/client@^2.0.0 \ - zod@^4.2.0 -``` - -For an MCP server, add the server package (and, for HTTP transports, the Node -and Express adapters): - -```bash -npm install -S @modelcontextprotocol/ext-apps \ - @modelcontextprotocol/client@^2.0.0 \ - @modelcontextprotocol/server@^2.0.0 \ - @modelcontextprotocol/node@^2.0.0 \ - @modelcontextprotocol/express@^2.0.0 \ - zod@^4.2.0 -``` - -The wire protocol is unchanged between `ext-apps` 1.x and 2.x: a 2.x View works -in a 1.x host and a 2.x host renders 1.x Views. See the -[migration guide](https://apps.extensions.modelcontextprotocol.io/api/documents/migrate-to-v2.html) -when upgrading from 1.x. - -**New here?** Start with the -[Quickstart Guide](https://apps.extensions.modelcontextprotocol.io/api/documents/quickstart.html) -to build your first MCP App. - -## Using the SDK - -The SDK serves three roles: app developers building interactive Views, host -developers embedding those Views, and MCP server authors registering tools with -UI metadata. - -| Package | Purpose | Docs | -| ------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -| `@modelcontextprotocol/ext-apps` | Build interactive Views (App class, PostMessageTransport) | [API Docs โ†’](https://apps.extensions.modelcontextprotocol.io/api/modules/app.html) | -| `@modelcontextprotocol/ext-apps/react` | React hooks for Views (useApp, useHostStyles, etc.) | [API Docs โ†’](https://apps.extensions.modelcontextprotocol.io/api/modules/_modelcontextprotocol_ext-apps_react.html) | -| `@modelcontextprotocol/ext-apps/app-bridge` | Embed and communicate with Views in your chat client | [API Docs โ†’](https://apps.extensions.modelcontextprotocol.io/api/modules/app-bridge.html) | -| `@modelcontextprotocol/ext-apps/server` | Register tools and resources on your MCP server | [API Docs โ†’](https://apps.extensions.modelcontextprotocol.io/api/modules/server-helpers.html) | - -There's no _supported_ host implementation in this repo (beyond the -[examples/basic-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) -example). - -The [MCP-UI](https://github.com/idosal/mcp-ui) client SDK offers a -fully-featured MCP Apps framework used by a few hosts. Clients may choose to use -it or roll their own implementation. +For more information, including instructions for installing the skills in your favorite AI coding agent, see the [agent skills guide](./docs/agent-skills.md). ## Examples -The -[`examples/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples) -directory contains demo apps showcasing real-world use cases. +The [`examples/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples) directory contains demo apps showcasing real-world use cases. | | | | @@ -206,9 +88,7 @@ directory contains 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 locally using [basic-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) (the reference host implementation included in this repo): ```bash git clone https://github.com/modelcontextprotocol/ext-apps.git @@ -221,64 +101,234 @@ 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: +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", "@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" + "-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" ] } } } ``` -## 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) | - -
+> [!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. ## 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 index d27ad71..4d957a2 100644 --- a/docs/agent-skills.md +++ b/docs/agent-skills.md @@ -1,19 +1,15 @@ --- 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: +This repository provides two 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 @@ -55,7 +51,7 @@ Then copy the skills from `plugins/mcp-apps/skills/` to your agent's skills dire ## 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. +Ask your agent "What skills do you have?" โ€” you should see `create-mcp-app` and `migrate-oai-app` among the available skills. ## Invoke a Skill @@ -63,8 +59,6 @@ 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. 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..6f5a1c3 100644 --- a/docs/migrate_from_openai_apps.md +++ b/docs/migrate_from_openai_apps.md @@ -1,7 +1,5 @@ --- 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 @@ -66,7 +64,7 @@ The server-side changes involve updating metadata structure and using helper fun #### Before (OpenAI) ```typescript -import { McpServer } from "@modelcontextprotocol/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; function createServer() { @@ -78,7 +76,7 @@ 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", @@ -126,7 +124,7 @@ function createServer() { #### After (MCP Apps) ```typescript -import { McpServer } from "@modelcontextprotocol/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerAppTool, registerAppResource, @@ -144,7 +142,7 @@ 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" } }, }, diff --git a/docs/overview.md b/docs/overview.md index 8eda95d..b2f5c6a 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -1,7 +1,5 @@ --- 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 @@ -27,7 +25,7 @@ MCP Apps is designed for graceful degradation. Hosts advertise their UI support 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. +See the [Client<>Server Capability Negotiation](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#clientserver-capability-negotiation) section of the specification. ## Architecture @@ -56,11 +54,7 @@ flowchart LR - **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. +The View acts as an MCP client, the Host acts as a proxy, and the Server is a standard MCP server. ## Lifecycle @@ -97,12 +91,12 @@ sequenceDiagram ``` 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. +2. **Initialization** โ€” When a UI tool is called, the Host renders the iframe. The View sends `ui/initialize` and receives host context (theme, capabilities, container dimensions). This handshake ensures the View is ready before receiving data. 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. +See the [Lifecycle](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#lifecycle) section of the specification for the complete sequence diagrams. ## UI Resources @@ -114,7 +108,7 @@ Resources are declared upfront, during tool registration. This design enables: - **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. +See the [UI Resource Format](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#ui-resource-format) section of the specification for the full schema. ## Tool-UI Linkage @@ -132,7 +126,7 @@ When this tool is called, the Host: 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. +See the [Resource Discovery](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#resource-discovery) section of the specification for details. ## Bidirectional Communication @@ -152,7 +146,7 @@ Views communicate with Hosts using JSON-RPC over [`postMessage`](https://develop - 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. +See the {@link app!App} class for the View-side API and the [Communication Protocol](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#communication-protocol) section of the specification. ## Tool Visibility @@ -174,7 +168,7 @@ Views use this context to adapt their presentation. For example, a chart might u 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. +See the [Host Context](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#host-context-in-mcpuiinitializeresult) section of the specification for the full schema. ## Theming @@ -189,7 +183,7 @@ Hosts provide CSS custom properties for colors, typography, and borders. Views u 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. +See the [Theming](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#theming) section of the specification for the full list of CSS variables. ## Display Modes @@ -201,17 +195,17 @@ Views can be displayed in different modes: 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. +See the [Display Modes](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/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. +Servers declare which external 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. +See the [Security Implications](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/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 +- [Specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx) โ€” Full protocol specification - [Quickstart](./quickstart.md) โ€” Build your first MCP App diff --git a/docs/patterns.md b/docs/patterns.md index 884de26..43d7519 100644 --- a/docs/patterns.md +++ b/docs/patterns.md @@ -1,7 +1,5 @@ --- 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 @@ -10,7 +8,7 @@ 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. +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 quantities, toggling settings, or other interactions that shouldn't appear in the model's tool list. ```ts source="../src/server/index.examples.ts#registerAppTool_appOnlyVisibility" @@ -19,7 +17,7 @@ registerAppTool( "update-quantity", { description: "Update item quantity in cart", - inputSchema: z.object({ itemId: z.string(), quantity: z.number() }), + inputSchema: { itemId: z.string(), quantity: z.number() }, _meta: { ui: { resourceUri: "ui://shop/cart.html", @@ -34,73 +32,7 @@ registerAppTool( ); ``` -> [!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). +_See [`examples/system-monitor-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/system-monitor-server) for a full implementation of this pattern._ ## Reading large amounts of data via chunked tool calls @@ -127,14 +59,14 @@ registerAppTool( { title: "Read Data Bytes", description: "Load binary data in chunks", - inputSchema: z.object({ + inputSchema: { 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"] } }, @@ -224,10 +156,9 @@ loadDataInChunks(resourceId, (loaded, total) => { }); ``` -> [!NOTE] -> For a full example that implements this pattern, see: [`examples/pdf-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server). +_See [`examples/pdf-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server) for a full implementation of this pattern._ -## Giving errors back to the model +## Giving errors back to model **Server-side**: Tool handler validates inputs and returns `{ isError: true, content: [...] }`. The model receives this error through the normal tool call response. @@ -251,67 +182,18 @@ try { } ``` -## Serving binary blobs via resources +## Matching host styling (CSS variables, theme, and fonts) -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: +Use the SDK's style helpers to apply host styling, then reference them in your CSS: +- **CSS variables** โ€” Use `var(--color-background-primary)`, etc. in your CSS - **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" +```tsx source="./patterns.tsx#hostStylingVanillaJs" function applyHostContext(ctx: McpUiHostContext) { if (ctx.theme) { applyDocumentTheme(ctx.theme); @@ -322,18 +204,12 @@ function applyHostContext(ctx: McpUiHostContext) { 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 +// Apply initial styles after connecting app.connect().then(() => { const ctx = app.getHostContext(); if (ctx) { @@ -345,72 +221,43 @@ app.connect().then(() => { **React:** -```tsx source="./patterns.tsx#hostContextReact" +```tsx source="./patterns.tsx#hostStylingReact" 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]); + // Apply all host styles (variables, theme, fonts) + useHostStyles(app, app?.getHostContext()); return (
- Styled with host CSS variables, fonts, and safe area insets +

Styled with host CSS variables and fonts

+

Uses [data-theme] selectors

); } ``` -> [!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). +_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) for full implementations of this pattern._ -## Entering / exiting fullscreen +## 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"); +if (ctx?.availableDisplayModes?.includes("fullscreen")) { + const target = ctx.displayMode === "fullscreen" ? "inline" : "fullscreen"; + const result = await app.requestDisplayMode({ mode: target }); + console.log("Now in:", result.mode); } ``` @@ -418,39 +265,17 @@ Listen for display mode changes via {@link app!App.onhostcontextchanged `onhostc ```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"; +app.onhostcontextchanged = (params) => { + if (params.displayMode) { + const isFullscreen = params.displayMode === "fullscreen"; + document.body.classList.toggle("fullscreen", isFullscreen); } }; ``` -In fullscreen mode, remove the container's border radius so content extends to the viewport edges: +_See [`examples/shadertoy-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/shadertoy-server) for a full implementation of this pattern._ -```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 +## 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: @@ -471,8 +296,7 @@ await app.updateModelContext({ }); ``` -> [!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). +_See [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) for a full implementation of this pattern._ ## Sending large follow-up messages @@ -497,12 +321,11 @@ await app.sendMessage({ }); ``` -> [!NOTE] -> For a full example that implements this pattern, see: [`examples/transcript-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/transcript-server). +_See [`examples/transcript-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/transcript-server) for a full implementation of this pattern._ ## 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. +To persist view state across conversation reloads (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`: @@ -522,56 +345,21 @@ return { ```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... - } +// 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(), + }, }; - -// 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. +_See [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) for a full implementation of this pattern._ -> [!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 out of view -## 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: +Views with animations, WebGL rendering, or polling can consume significant CPU/GPU even when scrolled out of view. 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" @@ -579,9 +367,9 @@ Views with animations, WebGL rendering, or polling can consume significant CPU/G const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { - animation.play(); // or startPolling(), etc + animation.play(); } else { - animation.pause(); // or stopPolling(), etc + animation.pause(); } }); }); @@ -595,33 +383,38 @@ app.onteardown = async () => { }; ``` -> [!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). +_See [`examples/shadertoy-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/shadertoy-server) for a full implementation of this pattern._ ## 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.
+Use {@link app!App.ontoolinputpartial `ontoolinputpartial`} to receive streaming tool arguments as they arrive, allowing you to show a loading preview before the complete input is available.
 
 
 ```ts source="../src/app.examples.ts#App_ontoolinputpartial_progressiveRendering"
-const codePreview = document.querySelector("#code-preview")!;
-const canvas = document.querySelector("#canvas")!;
+let toolInputs: Record | null = null;
+let toolInputsPartial: Record | null = null;
 
 app.ontoolinputpartial = (params) => {
-  codePreview.textContent = (params.arguments?.code as string) ?? "";
-  codePreview.style.display = "block";
-  canvas.style.display = "none";
+  toolInputsPartial = params.arguments as Record;
+  render();
 };
 
 app.ontoolinput = (params) => {
-  codePreview.style.display = "none";
-  canvas.style.display = "block";
-  render(params.arguments?.code as string);
+  toolInputs = params.arguments as Record;
+  toolInputsPartial = null;
+  render();
 };
+
+function render() {
+  if (toolInputs) {
+    renderFinalUI(toolInputs);
+  } else {
+    renderLoadingUI(toolInputsPartial); // e.g., shimmer with partial preview
+  }
+}
 ```
 
 > [!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).
+_See [`examples/threejs-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/threejs-server) for a full implementation of this pattern._
diff --git a/docs/patterns.tsx b/docs/patterns.tsx
index bb9eaa4..f13268f 100644
--- a/docs/patterns.tsx
+++ b/docs/patterns.tsx
@@ -14,83 +14,13 @@ import {
   applyHostStyleVariables,
 } from "../src/styles.js";
 import { randomUUID } from "node:crypto";
-import type {
-  CallToolResult,
-  ReadResourceResult,
-} from "@modelcontextprotocol/server";
+import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
 import type { McpUiHostContext } from "../src/types.js";
-import { useEffect, useState } from "react";
-import { useApp } from "../src/react/index.js";
+import { useApp, useHostStyles } from "../src/react/index.js";
 import { registerAppTool } from "../src/server/index.js";
-import { McpServer, ResourceTemplate } from "@modelcontextprotocol/server";
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
 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)
  */
@@ -113,14 +43,14 @@ function chunkedDataServer(server: McpServer) {
     {
       title: "Read Data Bytes",
       description: "Load binary data in chunks",
-      inputSchema: z.object({
+      inputSchema: {
         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"] } },
@@ -217,54 +147,10 @@ function chunkedDataClient(app: App, resourceId: string) {
 }
 
 /**
- * Example: Serving binary blobs via resources (server-side)
+ * Example: Unified host styling (theme, CSS variables, fonts)
  */
-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 hostStylingVanillaJs(app: App) {
+  //#region hostStylingVanillaJs
   function applyHostContext(ctx: McpUiHostContext) {
     if (ctx.theme) {
       applyDocumentTheme(ctx.theme);
@@ -275,81 +161,48 @@ function hostContextVanillaJs(app: App, mainEl: HTMLElement) {
     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
+  // Apply initial styles after connecting
   app.connect().then(() => {
     const ctx = app.getHostContext();
     if (ctx) {
       applyHostContext(ctx);
     }
   });
-  //#endregion hostContextVanillaJs
+  //#endregion hostStylingVanillaJs
 }
 
 /**
- * Example: Adapting to host context with React (CSS variables, theme, fonts, safe areas)
+ * Example: Host styling with React (CSS variables, theme, fonts)
  */
-function hostContextReact() {
-  //#region hostContextReact
+function hostStylingReact() {
+  //#region hostStylingReact
   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]);
+    // Apply all host styles (variables, theme, fonts)
+    useHostStyles(app, app?.getHostContext());
 
     return (
       
- Styled with host CSS variables, fonts, and safe area insets +

Styled with host CSS variables and fonts

+

Uses [data-theme] selectors

); } - //#endregion hostContextReact + //#endregion hostStylingReact } /** @@ -431,9 +284,9 @@ function visibilityBasedPause( const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { - animation.play(); // or startPolling(), etc + animation.play(); } else { - animation.pause(); // or stopPolling(), etc + animation.pause(); } }); }); @@ -449,14 +302,10 @@ function visibilityBasedPause( } // Suppress unused variable warnings -void pollingVanillaJs; -void pollingReact; void chunkedDataServer; void chunkedDataClient; -void binaryBlobResourceServer; -void binaryBlobResourceClient; -void hostContextVanillaJs; -void hostContextReact; +void hostStylingVanillaJs; +void hostStylingReact; void persistViewStateServer; void persistViewState; void visibilityBasedPause; diff --git a/docs/quickstart.md b/docs/quickstart.md index a021912..8146824 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,7 +1,5 @@ --- 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 @@ -21,7 +19,7 @@ This tutorial assumes you've built an MCP server before and are comfortable with We'll use the [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) to build the server. -You'll also need Node.js 20+. +You'll also need Node.js 18+. ## 1. Set up the project @@ -37,7 +35,7 @@ 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 @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk express cors npm install -D typescript vite vite-plugin-singlefile @types/express @types/cors @types/node tsx concurrently cross-env ``` @@ -46,12 +44,9 @@ Configure your [`package.json`](https://github.com/modelcontextprotocol/ext-apps ```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 pkg set scripts.start="concurrently 'cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch' 'tsx watch main.ts'" ``` -> [!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: @@ -59,7 +54,6 @@ npm pkg set scripts.start='concurrently --raw "cross-env NODE_ENV=development IN ```json source="../examples/quickstart/tsconfig.json" { "compilerOptions": { - "types": ["node", "vite/client"], "target": "ESNext", "lib": ["ESNext", "DOM", "DOM.Iterable"], "module": "ESNext", @@ -88,7 +82,6 @@ npm pkg set scripts.start='concurrently --raw "cross-env NODE_ENV=development IN ```json source="../examples/quickstart/tsconfig.server.json" { "compilerOptions": { - "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", @@ -115,7 +108,7 @@ npm pkg set scripts.start='concurrently --raw "cross-env NODE_ENV=development IN ```ts source="../examples/quickstart/vite.config.ts" -import { createLogger, defineConfig } from "vite"; +import { defineConfig } from "vite"; import { viteSingleFile } from "vite-plugin-singlefile"; const INPUT = process.env.INPUT; @@ -125,14 +118,7 @@ if (!INPUT) { 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, @@ -180,10 +166,9 @@ import { registerAppTool, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server"; -import { McpServer } from "@modelcontextprotocol/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import fs from "node:fs/promises"; import path from "node:path"; -import { z } from "zod"; const DIST_DIR = path.join(import.meta.dirname, "dist"); @@ -208,7 +193,7 @@ export function createServer(): McpServer { { title: "Get Time", description: "Returns the current server time.", - inputSchema: z.object({}), + inputSchema: {}, _meta: { ui: { resourceUri } }, // Links this tool to its UI resource }, async () => { @@ -243,10 +228,10 @@ export function createServer(): McpServer { ```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 { 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"; @@ -266,7 +251,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new NodeStreamableHTTPServerTransport({ + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); @@ -462,9 +447,8 @@ MCP server listening on http://localhost:3001/mcp ```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 ``` @@ -484,4 +468,4 @@ You've built your first MCP App! - **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 - **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 index a8eb333..06873da 100644 --- a/docs/testing-mcp-apps.md +++ b/docs/testing-mcp-apps.md @@ -1,7 +1,5 @@ --- 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 @@ -23,15 +21,10 @@ The [`basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/ex ```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 ``` - 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 @@ -66,8 +59,8 @@ For additional observability, open your browser's developer console. Basic-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) + - [Remote MCP servers (over HTTP)](https://claude.ai/docs/connectors/custom/remote-mcp) + - [Local MCP servers (over stdio)](https://claude.ai/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/) diff --git a/examples/basic-host/package.json b/examples/basic-host/package.json index cefe9d5..d3ce4f9 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.2", "type": "module", "scripts": { "build": "tsc --noEmit && concurrently \"cross-env INPUT=index.html vite build\" \"cross-env INPUT=sandbox.html vite build\"", @@ -11,11 +11,11 @@ "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": "../..", + "@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", @@ -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/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/implementation.ts b/examples/basic-host/src/implementation.ts index 5f680ff..434169a 100644 --- a/examples/basic-host/src/implementation.ts +++ b/examples/basic-host/src/implementation.ts @@ -1,12 +1,8 @@ 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 { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; import { getTheme, onThemeChange } from "./theme"; import { HOST_STYLE_VARIABLES } from "./host-styles"; @@ -26,7 +22,6 @@ export interface ServerInfo { name: string; client: Client; tools: Map; - resources: Map; appHtmlCache: Map; } @@ -41,12 +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() }; + return { name, client, tools, appHtmlCache: new Map() }; } async function connectWithFallback(serverUrl: URL): Promise { @@ -138,23 +128,14 @@ async function getUiResource(serverInfo: ServerInfo, uri: string): Promise { - 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 // 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..ec18e2b 100644 --- a/examples/basic-host/src/index.module.css +++ b/examples/basic-host/src/index.module.css @@ -1,7 +1,7 @@ /* Theme toggle button */ .themeToggle { position: fixed; - bottom: 1rem; + top: 1rem; right: 1rem; width: 2.5rem; height: 2.5rem; @@ -165,7 +165,7 @@ margin: 0; padding: 1rem; max-width: none; - background: var(--color-bg); + background: white; border: none; border-radius: 0; display: flex; @@ -201,30 +201,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 +235,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 +263,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; diff --git a/examples/basic-host/src/index.tsx b/examples/basic-host/src/index.tsx index 00c0d52..3d488a7 100644 --- a/examples/basic-host/src/index.tsx +++ b/examples/basic-host/src/index.tsx @@ -1,5 +1,5 @@ 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"; diff --git a/examples/basic-host/src/sandbox.ts b/examples/basic-host/src/sandbox.ts index 6f1dd93..1caf1c5 100644 --- a/examples/basic-host/src/sandbox.ts +++ b/examples/basic-host/src/sandbox.ts @@ -1,5 +1,5 @@ -import type { McpUiSandboxProxyReadyNotification, McpUiSandboxResourceReadyNotification } from "@modelcontextprotocol/ext-apps/app-bridge"; -import { buildAllowAttribute } from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { McpUiSandboxProxyReadyNotification, McpUiSandboxResourceReadyNotification } from "../../../dist/src/types"; +import { buildAllowAttribute } from "../../../dist/src/app-bridge"; const ALLOWED_REFERRER_PATTERN = /^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/; 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..b85fb90 100644 --- a/examples/basic-server-preact/README.md +++ b/examples/basic-server-preact/README.md @@ -26,29 +26,11 @@ Add to your MCP client configuration (stdio transport): } ``` -### 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 +52,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..7642632 100644 --- a/examples/basic-server-preact/main.ts +++ b/examples/basic-server-preact/main.ts @@ -4,10 +4,10 @@ * 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 { 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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new NodeStreamableHTTPServerTransport({ + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/basic-server-preact/package.json b/examples/basic-server-preact/package.json index 60aa40d..d58b2f4 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.2", "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,22 +17,17 @@ "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": "../..", + "@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", diff --git a/examples/basic-server-preact/server.ts b/examples/basic-server-preact/server.ts index dfec94a..bf14ea5 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,7 +28,7 @@ export function createServer(): McpServer { { title: "Get Time", description: "Returns the current server time as an ISO 8601 string.", - inputSchema: z.object({}), + inputSchema: {}, _meta: { ui: { resourceUri } }, // Links this tool to its UI resource }, async (): Promise => { diff --git a/examples/basic-server-preact/src/global.css b/examples/basic-server-preact/src/global.css index 801291f..08d4da1 100644 --- a/examples/basic-server-preact/src/global.css +++ b/examples/basic-server-preact/src/global.css @@ -1,92 +1,18 @@ -: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); +/* Server time fills remaining width for consistent E2E screenshot masking */ +#server-time { + flex: 1; + min-width: 0; } diff --git a/examples/basic-server-preact/src/mcp-app.module.css b/examples/basic-server-preact/src/mcp-app.module.css index ae08a8c..41a0456 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,48 @@ } > * + * { - margin-top: var(--spacing-sm); + margin-top: 0.5rem; } /* Server time row: flex layout for consistent mask width in E2E tests */ > p { display: flex; align-items: baseline; - gap: var(--spacing-xs); + gap: 0.25em; } /* 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..4b3f26c 100644 --- a/examples/basic-server-react/README.md +++ b/examples/basic-server-react/README.md @@ -28,29 +28,11 @@ Add to your MCP client configuration (stdio transport): } ``` -### 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 +54,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..ec187b6 100644 --- a/examples/basic-server-react/main.ts +++ b/examples/basic-server-react/main.ts @@ -4,10 +4,10 @@ * 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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new NodeStreamableHTTPServerTransport({ + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/basic-server-react/package.json b/examples/basic-server-react/package.json index ea51a8a..df612e0 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.2", "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,23 +27,18 @@ "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": "../..", + "@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", diff --git a/examples/basic-server-react/server.ts b/examples/basic-server-react/server.ts index d77c908..23f6dda 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,7 +29,7 @@ export function createServer(): McpServer { { title: "Get Time", description: "Returns the current server time as an ISO 8601 string.", - inputSchema: z.object({}), + inputSchema: {}, _meta: { ui: { resourceUri } }, // Links this tool to its UI resource }, async (): Promise => { diff --git a/examples/basic-server-react/src/global.css b/examples/basic-server-react/src/global.css index 801291f..08d4da1 100644 --- a/examples/basic-server-react/src/global.css +++ b/examples/basic-server-react/src/global.css @@ -1,92 +1,18 @@ -: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); +/* Server time fills remaining width for consistent E2E screenshot masking */ +#server-time { + flex: 1; + min-width: 0; } diff --git a/examples/basic-server-react/src/mcp-app.module.css b/examples/basic-server-react/src/mcp-app.module.css index ae08a8c..41a0456 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,48 @@ } > * + * { - margin-top: var(--spacing-sm); + margin-top: 0.5rem; } /* Server time row: flex layout for consistent mask width in E2E tests */ > p { display: flex; align-items: baseline; - gap: var(--spacing-xs); + gap: 0.25em; } /* 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..693fec3 100644 --- a/examples/basic-server-solid/README.md +++ b/examples/basic-server-solid/README.md @@ -26,29 +26,11 @@ Add to your MCP client configuration (stdio transport): } ``` -### 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 +52,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..c8d9de2 100644 --- a/examples/basic-server-solid/main.ts +++ b/examples/basic-server-solid/main.ts @@ -4,10 +4,10 @@ * Or: node dist/index.js [--stdio] */ -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; -import { createMcpExpressApp } from "@modelcontextprotocol/express"; -import type { McpServer } from "@modelcontextprotocol/server"; -import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node"; +import { 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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new NodeStreamableHTTPServerTransport({ + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/basic-server-solid/package.json b/examples/basic-server-solid/package.json index b3ca909..313cc3f 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.2", "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,22 +17,17 @@ "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": "../..", + "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", "solid-js": "1.9.10", - "zod": "^4.2.0" + "zod": "^4.1.13" }, "devDependencies": { "@types/cors": "^2.8.19", @@ -43,7 +38,7 @@ "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..2ed2d93 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,7 +28,7 @@ export function createServer(): McpServer { { title: "Get Time", description: "Returns the current server time as an ISO 8601 string.", - inputSchema: z.object({}), + inputSchema: {}, _meta: { ui: { resourceUri } }, // Links this tool to its UI resource }, async (): Promise => { diff --git a/examples/basic-server-solid/src/global.css b/examples/basic-server-solid/src/global.css index 801291f..08d4da1 100644 --- a/examples/basic-server-solid/src/global.css +++ b/examples/basic-server-solid/src/global.css @@ -1,92 +1,18 @@ -: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); +/* Server time fills remaining width for consistent E2E screenshot masking */ +#server-time { + flex: 1; + min-width: 0; } diff --git a/examples/basic-server-solid/src/mcp-app.module.css b/examples/basic-server-solid/src/mcp-app.module.css index ae08a8c..41a0456 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,48 @@ } > * + * { - margin-top: var(--spacing-sm); + margin-top: 0.5rem; } /* Server time row: flex layout for consistent mask width in E2E tests */ > p { display: flex; align-items: baseline; - gap: var(--spacing-xs); + gap: 0.25em; } /* 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..ff599af 100644 --- a/examples/basic-server-svelte/README.md +++ b/examples/basic-server-svelte/README.md @@ -26,29 +26,11 @@ Add to your MCP client configuration (stdio transport): } ``` -### 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 +52,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..6c50a25 100644 --- a/examples/basic-server-svelte/main.ts +++ b/examples/basic-server-svelte/main.ts @@ -4,10 +4,10 @@ * 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 { 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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new NodeStreamableHTTPServerTransport({ + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/basic-server-svelte/package.json b/examples/basic-server-svelte/package.json index 19fe4c9..608311e 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.2", "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,22 +17,17 @@ "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": "../..", + "@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", diff --git a/examples/basic-server-svelte/server.ts b/examples/basic-server-svelte/server.ts index 9414285..da603ca 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,7 +28,7 @@ export function createServer(): McpServer { { title: "Get Time", description: "Returns the current server time as an ISO 8601 string.", - inputSchema: z.object({}), + inputSchema: {}, _meta: { ui: { resourceUri } }, // Links this tool to its UI resource }, async (): Promise => { diff --git a/examples/basic-server-svelte/src/App.svelte b/examples/basic-server-svelte/src/App.svelte index cabcc31..48fbe66 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() { -
-

๐Ÿ”‘ 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..38a4f38 100644 --- a/examples/map-server/README.md +++ b/examples/map-server/README.md @@ -25,24 +25,6 @@ Add to your MCP client configuration (stdio transport): } ``` -### 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 +104,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/main.ts b/examples/map-server/main.ts index 8038394..f91f57b 100644 --- a/examples/map-server/main.ts +++ b/examples/map-server/main.ts @@ -4,10 +4,10 @@ * 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 { 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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new NodeStreamableHTTPServerTransport({ + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/map-server/mcp-app.html b/examples/map-server/mcp-app.html index b19eed4..473eb50 100644 --- a/examples/map-server/mcp-app.html +++ b/examples/map-server/mcp-app.html @@ -1,90 +1,72 @@ - + - - - + + + CesiumJS Globe - - + +
Loading globe...
- + diff --git a/examples/map-server/package.json b/examples/map-server/package.json index cc4585f..4cfab8f 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.2", "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", @@ -17,23 +17,20 @@ "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: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": "../..", + "@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", diff --git a/examples/map-server/server.ts b/examples/map-server/server.ts index 5767d46..dbe2154 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: [ @@ -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..1475d81 100644 --- a/examples/map-server/src/mcp-app.ts +++ b/examples/map-server/src/mcp-app.ts @@ -6,8 +6,7 @@ * 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"; +import type { ContentBlock } from "@modelcontextprotocol/sdk/spec.types.js"; // TypeScript declaration for Cesium loaded from CDN // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -398,17 +397,6 @@ async function initCesium(): Promise { // CesiumJS sets image-rendering: pixelated by default which looks bad on scaled displays // Setting to "auto" allows the browser to apply smooth interpolation cesiumViewer.canvas.style.imageRendering = "auto"; - // Prevent touch events from propagating to the parent scroll view. - // CesiumJS uses pointer events internally, which don't suppress native - // scroll gesture recognition on touch devices. Explicit non-passive touch - // listeners with preventDefault() are needed. - for (const eventName of ["touchstart", "touchmove"] as const) { - cesiumViewer.canvas.addEventListener( - eventName, - (e: TouchEvent) => e.preventDefault(), - { passive: false }, - ); - } // Note: DO NOT set resolutionScale = devicePixelRatio here! // When useBrowserRecommendedResolution: false, Cesium already uses devicePixelRatio. // Setting resolutionScale = devicePixelRatio would double the scaling (e.g., 2x2=4x on Retina) @@ -560,76 +548,6 @@ function setViewToBoundingBox(cesiumViewer: any, bbox: BoundingBox): void { ); } -/** - * Fly camera to view a bounding box with animation - */ -function flyToBoundingBox( - cesiumViewer: any, - bbox: BoundingBox, - duration = 2, -): Promise { - return new Promise((resolve) => { - const { destination, centerLon, centerLat, height } = - calculateDestination(bbox); - - log.info("flyTo destination:", centerLon, centerLat, "height:", height); - - cesiumViewer.camera.flyTo({ - destination, - orientation: { - heading: 0, - pitch: Cesium.Math.toRadians(-90), // Look straight down - roll: 0, - }, - duration, - complete: () => { - log.info( - "flyTo complete, camera height:", - cesiumViewer.camera.positionCartographic.height, - ); - resolve(); - }, - cancel: () => { - log.warn("flyTo cancelled"); - resolve(); - }, - }); - }); -} - -// Label element for displaying location info -let labelElement: HTMLDivElement | null = null; - -/** - * Set or clear the label displayed on the map - */ -function setLabel(text?: string): void { - if (!labelElement) { - labelElement = document.createElement("div"); - labelElement.style.cssText = ` - position: absolute; - top: 10px; - left: 10px; - background: rgba(0, 0, 0, 0.7); - color: white; - padding: 8px 12px; - border-radius: 4px; - font-family: sans-serif; - font-size: 14px; - z-index: 100; - pointer-events: none; - `; - document.body.appendChild(labelElement); - } - - if (text) { - labelElement.textContent = text; - labelElement.style.display = "block"; - } else { - labelElement.style.display = "none"; - } -} - /** * Wait for globe tiles to finish loading */ @@ -705,16 +623,6 @@ function updateFullscreenButton(): void { // Show button only if fullscreen is available btn.style.display = canFullscreen ? "flex" : "none"; - // Position button respecting safe area insets - const insets = context?.safeAreaInsets; - btn.style.top = `${10 + (insets?.top ?? 0)}px`; - btn.style.right = `${10 + (insets?.right ?? 0)}px`; - - // Always show button on touch devices (hover doesn't work on mobile) - if (context?.deviceCapabilities?.touch) { - btn.style.opacity = canFullscreen ? "0.7" : "0"; - } - // Toggle icons based on current mode const isFullscreen = currentDisplayMode === "fullscreen"; expandIcon.style.display = isFullscreen ? "none" : "block"; @@ -810,8 +718,8 @@ app.onhostcontextchanged = (params) => { ); } - // Update button if available modes or safe area changed - if (params.availableDisplayModes || params.safeAreaInsets) { + // Update button if available modes changed + if (params.availableDisplayModes) { updateFullscreenButton(); } }; @@ -872,110 +780,57 @@ app.ontoolinput = async (params) => { } }; -// Register tools for the model to interact with this component -app.registerTool( - "navigate-to", - { - title: "Navigate To", - description: "Navigate the globe to a new bounding box location", - inputSchema: z.object({ - west: z.number().describe("Western longitude (-180 to 180)"), - south: z.number().describe("Southern latitude (-90 to 90)"), - east: z.number().describe("Eastern longitude (-180 to 180)"), - north: z.number().describe("Northern latitude (-90 to 90)"), - duration: z - .number() - .optional() - .describe("Animation duration in seconds (default: 2)"), - label: z.string().optional().describe("Optional label to display"), - }), - }, - async (args) => { - if (!viewer) { - return { - content: [ - { type: "text" as const, text: "Error: Viewer not initialized" }, - ], - isError: true, - }; - } +/* + Register tools for the model to interact w/ this component + Needs https://github.com/modelcontextprotocol/ext-apps/pull/72 +*/ +// app.registerTool( +// "navigate-to", +// { +// title: "Navigate To", +// description: "Navigate the globe to a new bounding box location", +// inputSchema: z.object({ +// west: z.number().describe("Western longitude (-180 to 180)"), +// south: z.number().describe("Southern latitude (-90 to 90)"), +// east: z.number().describe("Eastern longitude (-180 to 180)"), +// north: z.number().describe("Northern latitude (-90 to 90)"), +// duration: z +// .number() +// .optional() +// .describe("Animation duration in seconds (default: 2)"), +// label: z.string().optional().describe("Optional label to display"), +// }), +// }, +// async (args) => { +// if (!viewer) { +// return { +// content: [ +// { type: "text" as const, text: "Error: Viewer not initialized" }, +// ], +// isError: true, +// }; +// } - const bbox: BoundingBox = { - west: args.west, - south: args.south, - east: args.east, - north: args.north, - }; +// const bbox: BoundingBox = { +// west: args.west, +// south: args.south, +// east: args.east, +// north: args.north, +// }; - await flyToBoundingBox(viewer, bbox, args.duration ?? 2); - setLabel(args.label); +// await flyToBoundingBox(viewer, bbox, args.duration ?? 2); +// setLabel(args.label); - return { - content: [ - { - type: "text" as const, - text: `Navigated to: W:${bbox.west.toFixed(4)}, S:${bbox.south.toFixed(4)}, E:${bbox.east.toFixed(4)}, N:${bbox.north.toFixed(4)}${args.label ? ` (${args.label})` : ""}`, - }, - ], - }; - }, -); - -app.registerTool( - "get-current-view", - { - title: "Get Current View", - description: - "Get the current camera position and bounding box visible on the globe", - }, - async () => { - if (!viewer) { - return { - content: [ - { type: "text" as const, text: "Error: Viewer not initialized" }, - ], - isError: true, - }; - } - - const camera = viewer.camera; - const positionCartographic = camera.positionCartographic; - const latitude = Cesium.Math.toDegrees(positionCartographic.latitude); - const longitude = Cesium.Math.toDegrees(positionCartographic.longitude); - const height = positionCartographic.height; - - // Get the visible bounding box - const rectangle = viewer.camera.computeViewRectangle(); - let bbox = null; - if (rectangle) { - bbox = { - west: Cesium.Math.toDegrees(rectangle.west), - south: Cesium.Math.toDegrees(rectangle.south), - east: Cesium.Math.toDegrees(rectangle.east), - north: Cesium.Math.toDegrees(rectangle.north), - }; - } - - const viewData = { - camera: { - latitude, - longitude, - height, - }, - bbox, - }; - - return { - content: [ - { - type: "text" as const, - text: JSON.stringify(viewData, null, 2), - }, - ], - structuredContent: viewData, - }; - }, -); +// return { +// content: [ +// { +// type: "text" as const, +// text: `Navigated to: W:${bbox.west.toFixed(4)}, S:${bbox.south.toFixed(4)}, E:${bbox.east.toFixed(4)}, N:${bbox.north.toFixed(4)}${args.label ? ` (${args.label})` : ""}`, +// }, +// ], +// }; +// }, +// ); // Handle tool result - extract viewUUID and restore persisted view if available app.ontoolresult = async (result) => { diff --git a/examples/map-server/tsconfig.json b/examples/map-server/tsconfig.json index b028ff5..535267b 100644 --- a/examples/map-server/tsconfig.json +++ b/examples/map-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/map-server/tsconfig.server.json b/examples/map-server/tsconfig.server.json index 27eb28f..05ddd8e 100644 --- a/examples/map-server/tsconfig.server.json +++ b/examples/map-server/tsconfig.server.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/pdf-server/.gitignore b/examples/pdf-server/.gitignore index a856bb2..849ddff 100644 --- a/examples/pdf-server/.gitignore +++ b/examples/pdf-server/.gitignore @@ -1,2 +1 @@ dist/ -*.jsonl diff --git a/examples/pdf-server/.mcpbignore b/examples/pdf-server/.mcpbignore deleted file mode 100644 index 3f7859c..0000000 --- a/examples/pdf-server/.mcpbignore +++ /dev/null @@ -1,24 +0,0 @@ -# Source files (built output in dist/ is what we need) -*.ts -tsconfig*.json -vite.config.ts -src/ - -# Tests -*.test.* - -# Debug logs -*.jsonl - -# Development assets -screenshot.png -grid-cell.png -pdf-icon.svg - -# Plugin directory (separate distribution path) -plugin/ - -# Git and CI -.git/ -.github/ -.gitignore diff --git a/examples/pdf-server/README.md b/examples/pdf-server/README.md index 2bee370..420fc99 100644 --- a/examples/pdf-server/README.md +++ b/examples/pdf-server/README.md @@ -25,24 +25,6 @@ Add to your MCP client configuration (stdio transport): } ``` -### Local Development - -To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path): - -```json -{ - "mcpServers": { - "pdf": { - "command": "bash", - "args": [ - "-c", - "cd ~/code/ext-apps/examples/pdf-server && npm run build >&2 && node dist/index.js --stdio" - ] - } - } -} -``` - ## What This Example Demonstrates ### 1. Chunked Data Through Size-Limited Tool Calls @@ -63,10 +45,7 @@ On some host platforms, tool calls have size limits, so large PDFs cannot be sen ```typescript // Load in chunks with progress while (hasMore) { - const chunk = await app.callServerTool({ - name: "read_pdf_bytes", - arguments: { url, offset }, - }); + const chunk = await app.callServerTool("read_pdf_bytes", { url, offset }); chunks.push(base64ToBytes(chunk.bytes)); offset += chunk.byteCount; hasMore = chunk.hasMore; @@ -149,198 +128,41 @@ bun examples/pdf-server/main.ts ./local.pdf https://arxiv.org/pdf/2401.00001.pdf bun examples/pdf-server/main.ts --stdio ./papers/ ``` -### Additional Flags - -- `--debug` โ€” Enable verbose server-side logging. -- `--enable-interact` โ€” Enable the `interact` tool in HTTP mode (see [Deployment](#deployment)). Not needed for stdio. -- `--writeable-uploads-root` โ€” Allow saving annotated PDFs back to files under client roots named `uploads` (Claude Desktop mounts attachments there; writes are refused by default). - -## Deployment - -The `interact` tool relies on an in-memory command queue (server enqueues โ†’ viewer polls). This constrains how the server can be deployed: - -- **stdio** (Claude Desktop) โ€” `interact` is always enabled. The server runs as a single long-lived process, so the in-memory queue works. -- **HTTP, single instance** โ€” Pass `--enable-interact` to opt in. Works as long as all requests land on the same process. -- **HTTP, stateless / multi-instance** โ€” `interact` will not work. Commands enqueued on one instance are invisible to viewers polling another. Leave the flag off; the tool will not be registered. - -## Security: Client Roots - -MCP clients may advertise **roots** โ€” `file://` URIs pointing to directories on the client's file system. The server uses these to allow access to local files under those directories. - -- **Stdio mode** (`--stdio`): Client roots are **always enabled** โ€” the client is typically on the same machine (e.g. Claude Desktop), so the roots are safe. -- **HTTP mode** (default): Client roots are **ignored** by default โ€” the client may be remote, and its roots would be resolved against the server's filesystem. To opt in, pass `--use-client-roots`: - -```bash -# Trust that the HTTP client is local and its roots are safe -bun examples/pdf-server/main.ts --use-client-roots -``` - -When roots are ignored the server logs: - -``` -[pdf-server] Client roots are ignored (default for remote transports). Pass --use-client-roots to allow the client to expose local directories. -``` - ## Allowed Sources -- **Local files**: Must be passed as CLI arguments (or via client roots when enabled) +- **Local files**: Must be passed as CLI arguments - **Remote URLs**: arxiv.org, biorxiv.org, medrxiv.org, chemrxiv.org, zenodo.org, osf.io, hal.science, ssrn.com, and more ## Tools -| Tool | Visibility | Purpose | -| ---------------- | ---------- | ------------------------------------------------------------------- | -| `list_pdfs` | Model | List available local files and origins | -| `display_pdf` | Model + UI | Display interactive viewer | -| `interact`ยน | Model | Navigate, annotate, search, extract pages, fill forms, save to file | -| `read_pdf_bytes` | App only | Stream PDF data in chunks | -| `save_pdf` | App only | Save annotated PDF back to local file | - -ยน stdio only by default; in HTTP mode requires `--enable-interact` โ€” see [Deployment](#deployment). - -## Example Prompts - -After the model calls `display_pdf`, it receives the `viewUUID` and a description of all capabilities. Here are example prompts and follow-ups that exercise annotation features: - -### Annotating - -> **User:** Show me the Attention Is All You Need paper -> -> _Model calls `display_pdf` โ†’ viewer opens_ -> -> **User:** Highlight the title and add an APPROVED stamp on the first page. -> -> _Model calls `interact` with `highlight_text` for the title and `add_annotations` with a stamp_ - -> **User:** Can you annotate this PDF? Mark important sections for me. -> -> _Model calls `interact` with `get_text` to read content first, then `add_annotations` with highlights/notes_ - -> **User:** Add a note on page 1 saying "Key contribution" at position (200, 500), and highlight the abstract. -> -> _Model calls `interact` with `add_annotations` containing a `note` and either `highlight_text` or a `highlight` annotation_ - -### Navigation & Search - -> **User:** Search for "self-attention" in the paper. -> -> _Model calls `interact` with action `search`, query `"self-attention"`_ - -> **User:** Go to page 5. -> -> _Model calls `interact` with action `navigate`, page `5`_ - -### Page Extraction - -> **User:** Give me the text of pages 1โ€“3. -> -> _Model calls `interact` with action `get_text`, intervals `[{start:1, end:3}]`_ - -> **User:** Take a screenshot of the first page. -> -> _Model calls `interact` with action `get_screenshot`, page `1`_ - -### Stamps & Form Filling - -> **User:** Stamp this document as CONFIDENTIAL on every page. -> -> _Model calls `interact` with `add_annotations` containing `stamp` annotations on each page_ - -> **User:** Fill in the "Name" field with "Alice" and "Date" with "2026-02-26". -> -> _Model calls `interact` with action `fill_form`, fields `[{name:"Name", value:"Alice"}, {name:"Date", value:"2026-02-26"}]`_ - -### Saving - -> **User:** Save the annotated version as `/docs/contract-signed.pdf`. -> -> _Model calls `interact` with action `save_as`, path `/docs/contract-signed.pdf`. Fails if the file exists; pass `overwrite: true` to replace. The target path must be under a mounted directory root._ - -> **User:** Save my changes back to the file. -> -> _Model calls `interact` with action `save_as`, `overwrite: true` (no `path`). Overwrites the original โ€” same writability gate as the viewer's save button. Only works for local files; remote PDFs need an explicit `path`._ - -## Testing - -### E2E Tests (Playwright) - -```bash -# Run annotation E2E tests (renders annotations in a real browser) -npx playwright test tests/e2e/pdf-annotations.spec.ts - -# Run all PDF server tests -npx playwright test -g "PDF Server" -``` - -### API Prompt Discovery Tests - -These tests verify that Claude can discover and use annotation capabilities by calling the Anthropic Messages API with the tool schemas. They are **disabled by default** โ€” skipped unless `ANTHROPIC_API_KEY` is set: - -```bash -ANTHROPIC_API_KEY=sk-ant-... npx playwright test tests/e2e/pdf-annotations-api.spec.ts -``` - -The API tests simulate a conversation where `display_pdf` has already been called, then send a follow-up user message and verify the model uses annotation actions (or at least the `interact` tool). Three scenarios are tested: - -| Scenario | User prompt | Expected model behavior | -| -------------------- | ----------------------------------------------------------------- | ------------------------------------------ | -| Direct annotation | "Highlight the title and add an APPROVED stamp" | Uses `highlight_text` or `add_annotations` | -| Capability discovery | "Can you annotate this PDF?" | Uses interact or mentions annotations | -| Specific notes | "Add a note saying 'Key contribution' and highlight the abstract" | Uses `interact` tool | +| Tool | Visibility | Purpose | +| ---------------- | ---------- | -------------------------------------- | +| `list_pdfs` | Model | List available local files and origins | +| `display_pdf` | Model + UI | Display interactive viewer | +| `read_pdf_bytes` | App only | Stream PDF data in chunks | ## Architecture ``` -server.ts # MCP server + tools -main.ts # CLI entry point +server.ts # MCP server + tools +main.ts # CLI entry point src/ -โ”œโ”€โ”€ mcp-app.ts # Interactive viewer UI (PDF.js) -โ”œโ”€โ”€ pdf-annotations.ts # Annotation types, diff model, PDF import/export -โ””โ”€โ”€ pdf-annotations.test.ts # Unit tests for annotation module +โ””โ”€โ”€ mcp-app.ts # Interactive viewer UI (PDF.js) ``` ## Key Patterns Shown -| Pattern | Implementation | -| ----------------------------- | -------------------------------------------------------------- | -| App-only tools | `_meta: { ui: { visibility: ["app"] } }` | -| Chunked responses | `hasMore` + `offset` pagination | -| Model context | `app.updateModelContext()` | -| Display modes | `app.requestDisplayMode()` | -| External links | `app.openLink()` | -| View persistence | `viewUUID` + localStorage | -| Theming | `applyDocumentTheme()` + CSS `light-dark()` | -| Annotations | DOM overlays synced with proper PDF annotation dicts | -| Annotation import | Load existing PDF annotations via PDF.js `getAnnotations()` | -| Diff-based persistence | localStorage stores only additions/removals vs PDF baseline | -| Proper PDF export | pdf-lib low-level API creates real `/Type /Annot` dictionaries | -| Save to file | App-only `save_pdf` tool writes annotated bytes back to disk | -| Dirty flag | `*` prefix on title when unsaved local changes exist | -| Command queue | Server enqueues โ†’ client polls + processes | -| File download | `app.downloadFile()` for annotated PDF | -| Floating panel with anchoring | Magnetic corner-snapping panel for annotation list | -| Drag, resize, rotate | Interactive annotation handles with undo/redo | -| Keyboard shortcuts | Ctrl+Z/Y (undo/redo), Ctrl+S (save), Ctrl+F (search), โŒ˜Enter | - -### Annotation Types - -Supported annotation types (synced with PDF.js): - -| Type | Properties | PDF Subtype | -| --------------- | ------------------------------------------------------------------ | ------------ | -| `highlight` | `rects`, `color?`, `content?` | `/Highlight` | -| `underline` | `rects`, `color?` | `/Underline` | -| `strikethrough` | `rects`, `color?` | `/StrikeOut` | -| `note` | `x`, `y`, `content`, `color?` | `/Text` | -| `rectangle` | `x`, `y`, `width`, `height`, `color?`, etc. | `/Square` | -| `circle` | `x`, `y`, `width`, `height`, `color?`, etc. | `/Circle` | -| `line` | `x1`, `y1`, `x2`, `y2`, `color?` | `/Line` | -| `freetext` | `x`, `y`, `content`, `fontSize?`, `color?` | `/FreeText` | -| `stamp` | `x`, `y`, `label`, `color?`, `rotation?` | `/Stamp` | -| `image` | `x`, `y`, `width`, `height`, `imageData?`/`imageUrl?`, `rotation?` | `/Stamp` | +| Pattern | Implementation | +| ----------------- | ------------------------------------------- | +| App-only tools | `_meta: { ui: { visibility: ["app"] } }` | +| Chunked responses | `hasMore` + `offset` pagination | +| Model context | `app.updateModelContext()` | +| Display modes | `app.requestDisplayMode()` | +| External links | `app.openLink()` | +| View persistence | `viewUUID` + localStorage | +| Theming | `applyDocumentTheme()` + CSS `light-dark()` | ## Dependencies -- `pdfjs-dist`: PDF rendering and annotation import (frontend only) -- `pdf-lib`: Client-side PDF modification โ€” creates proper PDF annotation dictionaries for export +- `pdfjs-dist`: PDF rendering (frontend only) - `@modelcontextprotocol/ext-apps`: MCP Apps SDK diff --git a/examples/pdf-server/build-mcpb.mjs b/examples/pdf-server/build-mcpb.mjs deleted file mode 100644 index 2917334..0000000 --- a/examples/pdf-server/build-mcpb.mjs +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env node -/** - * Build a self-contained .mcpb bundle for pdf-server. - * - * `mcpb pack` zips whatever is on disk; in this monorepo all runtime deps - * are hoisted to the root node_modules, so packing in-place produces a - * bundle with no pdfjs-dist/ajv/etc. that crashes in Claude Desktop. - * - * This script stages dist/ + manifest into a clean temp dir, runs a fresh - * non-workspace `npm install --omit=dev --omit=optional` (the polyfill in - * dist/pdfjs-polyfill.js makes @napi-rs/canvas's ~130MB of native binaries - * unnecessary), syncs the manifest version to package.json, then packs. - */ - -import { - cpSync, - rmSync, - mkdirSync, - readFileSync, - writeFileSync, -} from "node:fs"; -import { execSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import path from "node:path"; - -const here = path.dirname(fileURLToPath(import.meta.url)); -const stage = path.join(here, ".mcpb-stage"); -const out = path.join(here, "pdf-server.mcpb"); - -const pkg = JSON.parse(readFileSync(path.join(here, "package.json"), "utf8")); -const manifest = JSON.parse( - readFileSync(path.join(here, "manifest.json"), "utf8"), -); -manifest.version = pkg.version; - -rmSync(stage, { recursive: true, force: true }); -mkdirSync(stage); - -for (const f of ["dist", "icon.png", "README.md", ".mcpbignore"]) { - cpSync(path.join(here, f), path.join(stage, f), { recursive: true }); -} -writeFileSync( - path.join(stage, "manifest.json"), - JSON.stringify(manifest, null, 2), -); -// The bundle must ship the ext-apps build from this checkout, not whatever -// the registry has: pack the repo root and point the staged manifest at it. -const repoRoot = path.resolve(here, "..", ".."); -const packed = JSON.parse( - execSync( - "npm pack --json --ignore-scripts --pack-destination " + - JSON.stringify(stage), - { - cwd: repoRoot, - encoding: "utf8", - }, - ).replace(/^[^[]*/, ""), -); -pkg.dependencies["@modelcontextprotocol/ext-apps"] = - "file:./" + packed[0].filename; -writeFileSync(path.join(stage, "package.json"), JSON.stringify(pkg, null, 2)); - -const run = (cmd) => execSync(cmd, { cwd: stage, stdio: "inherit" }); -run( - "npm install --omit=dev --omit=optional --no-audit --no-fund --no-package-lock " + - "--registry=https://registry.npmjs.org/", -); -run(`npx -y @anthropic-ai/mcpb pack . ${JSON.stringify(out)}`); - -rmSync(stage, { recursive: true, force: true }); -console.log(`\nโœ… ${path.relative(process.cwd(), out)}`); diff --git a/examples/pdf-server/grid-cell.png b/examples/pdf-server/grid-cell.png index 0e6e571..5acd222 100644 Binary files a/examples/pdf-server/grid-cell.png and b/examples/pdf-server/grid-cell.png differ diff --git a/examples/pdf-server/icon.png b/examples/pdf-server/icon.png deleted file mode 100644 index fb7ea3e..0000000 Binary files a/examples/pdf-server/icon.png and /dev/null differ diff --git a/examples/pdf-server/main.ts b/examples/pdf-server/main.ts index bb7b57f..310a312 100644 --- a/examples/pdf-server/main.ts +++ b/examples/pdf-server/main.ts @@ -5,11 +5,10 @@ */ import fs from "node:fs"; -import path from "node:path"; -import { createMcpExpressApp } from "@modelcontextprotocol/express"; -import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node"; -import type { McpServer } from "@modelcontextprotocol/server"; -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; +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 { @@ -20,10 +19,8 @@ import { pathToFileUrl, fileUrlToPath, allowedLocalFiles, - cliLocalFiles, + allowedRemoteOrigins, DEFAULT_PDF, - allowedLocalDirs, - writeFlags, } from "./server.js"; /** @@ -39,7 +36,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new NodeStreamableHTTPServerTransport({ + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); @@ -91,37 +88,14 @@ export async function startStdioServer( await createServer().connect(new StdioServerTransport()); } -function parseArgs(): { - urls: string[]; - stdio: boolean; - useClientRoots: boolean; - enableInteract: boolean; - debug: boolean; -} { +function parseArgs(): { urls: string[]; stdio: boolean } { const args = process.argv.slice(2); const urls: string[] = []; let stdio = false; - let useClientRoots = false; - let enableInteract = false; - let debug = false; for (const arg of args) { if (arg === "--stdio") { stdio = true; - } else if (arg === "--use-client-roots") { - useClientRoots = true; - } else if (arg === "--enable-interact") { - // Force-enable interact for HTTP mode. Only use when running a - // single long-lived server process (e.g. the e2e test harness) โ€” - // the command queue is in-memory per-process, so stateless - // multi-instance deployments will drop commands. - enableInteract = true; - } else if (arg === "--debug") { - debug = true; - } else if (arg === "--writeable-uploads-root") { - // Claude Desktop mounts attachments under a dir root named "uploads"; - // by default we refuse to write there. This flag opts back in. - writeFlags.allowUploadsRoot = true; } else if (!arg.startsWith("-")) { // Convert local paths to file:// URLs, normalize arxiv URLs let url = arg; @@ -138,32 +112,19 @@ function parseArgs(): { } } - return { - urls: urls.length > 0 ? urls : [DEFAULT_PDF], - stdio, - useClientRoots, - enableInteract, - debug, - }; + return { urls: urls.length > 0 ? urls : [DEFAULT_PDF], stdio }; } async function main() { - const { urls, stdio, useClientRoots, enableInteract, debug } = parseArgs(); + const { urls, stdio } = parseArgs(); // Register local files in whitelist for (const url of urls) { if (isFileUrl(url)) { - const filePath = path.resolve(fileUrlToPath(url)); + const filePath = fileUrlToPath(url); if (fs.existsSync(filePath)) { - const s = fs.statSync(filePath); - if (s.isFile()) { - allowedLocalFiles.add(filePath); - cliLocalFiles.add(filePath); - console.error(`[pdf-server] Registered local file: ${filePath}`); - } else if (s.isDirectory()) { - allowedLocalDirs.add(filePath); - console.error(`[pdf-server] Registered local directory: ${filePath}`); - } + allowedLocalFiles.add(filePath); + console.error(`[pdf-server] Registered local file: ${filePath}`); } else { console.error(`[pdf-server] Warning: File not found: ${filePath}`); } @@ -171,23 +132,14 @@ async function main() { } console.error(`[pdf-server] Ready (${urls.length} URL(s) configured)`); + console.error( + `[pdf-server] Allowed origins: ${[...allowedRemoteOrigins].join(", ")}`, + ); if (stdio) { - // stdio โ†’ client is local (e.g. Claude Desktop), roots are safe - await startStdioServer(() => - createServer({ enableInteract: true, useClientRoots: true, debug }), - ); + await startStdioServer(createServer); } else { - // HTTP โ†’ client is remote, only honour roots with explicit opt-in - if (!useClientRoots) { - console.error( - "[pdf-server] Client roots are ignored (default for remote transports). " + - "Pass --use-client-roots to allow the client to expose local directories.", - ); - } - await startStreamableHTTPServer(() => - createServer({ useClientRoots, enableInteract, debug }), - ); + await startStreamableHTTPServer(createServer); } } diff --git a/examples/pdf-server/manifest.json b/examples/pdf-server/manifest.json deleted file mode 100644 index f4b897e..0000000 --- a/examples/pdf-server/manifest.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "manifest_version": "0.3", - "name": "@modelcontextprotocol/server-pdf", - "display_name": "pdf-viewer", - "version": "1.0.1", - "description": "Read and interact with PDF files using an interactive viewer with search, navigation, and text extraction", - "author": { - "name": "Anthropic", - "url": "https://www.anthropic.com" - }, - "homepage": "https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server", - "repository": { - "type": "git", - "url": "https://github.com/modelcontextprotocol/ext-apps" - }, - "support": "https://github.com/modelcontextprotocol/ext-apps/issues", - "icon": "icon.png", - "server": { - "type": "node", - "entry_point": "dist/index.js", - "mcp_config": { - "command": "node", - "args": ["${__dirname}/dist/index.js", "--stdio"], - "env": {} - } - }, - "tools": [ - { - "name": "list_pdfs", - "description": "List available PDF files that can be displayed" - }, - { - "name": "display_pdf", - "description": "Display an interactive PDF viewer with search and navigation" - }, - { - "name": "interact", - "description": "Interact with a PDF viewer: annotate, navigate, search, extract text/screenshots, fill forms" - } - ], - "keywords": ["pdf", "documents", "viewer", "reading"], - "license": "MIT", - "compatibility": { - "platforms": ["darwin", "win32", "linux"], - "runtimes": { "node": ">=18.0.0" } - }, - "privacy_policies": ["https://www.anthropic.com/privacy"] -} diff --git a/examples/pdf-server/mcp-app.html b/examples/pdf-server/mcp-app.html index cf1545a..cf21723 100644 --- a/examples/pdf-server/mcp-app.html +++ b/examples/pdf-server/mcp-app.html @@ -11,6 +11,10 @@

Loading PDF...

+ +

@@ -19,29 +23,12 @@

An error occurred

- - -