diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6e0c78f..6848e4c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -24,7 +24,8 @@ jobs:
- uses: actions/setup-node@v6
with:
- node-version: "20"
+ node-version: "22"
+ cache: npm
- run: npm ci
@@ -71,12 +72,17 @@ jobs:
- uses: actions/setup-node@v6
with:
- node-version: "20"
+ node-version: "22"
+ cache: npm
- - run: npm install
+ - run: npm ci
- run: npm run build
+ - name: Validate TypeDoc links
+ if: runner.os == 'Linux' && matrix.name == 'Linux x64'
+ run: npm exec typedoc -- --treatValidationWarningsAsErrors --emit none
+
- run: npm run examples:build
- name: Verify generated schemas are up-to-date
@@ -97,9 +103,32 @@ jobs:
- name: Build MCPB bundle (pdf-server)
if: runner.os == 'Linux' && matrix.name == 'Linux x64'
- run: npx -y @anthropic-ai/mcpb pack
+ 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:
@@ -111,14 +140,17 @@ jobs:
- uses: actions/setup-node@v6
with:
- node-version: "20"
+ node-version: "22"
+ cache: npm
- uses: astral-sh/setup-uv@v7
- 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 chromium
+ run: npx playwright install --with-deps --only-shell chromium
- name: Run E2E tests
run: npx playwright test --reporter=list
@@ -147,13 +179,18 @@ jobs:
shell: wsl-bash {0}
run: |
sudo apt-get update
- curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
+ curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
+ # The checkout lives on the Windows drive, which WSL reaches over 9P;
+ # writing node_modules there is what makes this job slow (npm ci alone
+ # took about 3 minutes). Copy the tree onto the distro's ext4 filesystem
+ # first, as Microsoft's WSL guidance recommends for anything I/O heavy.
- name: Build and test in WSL
shell: wsl-bash {0}
run: |
- npm install
+ cp -r "$(pwd)" ~/ext-apps && cd ~/ext-apps
+ npm ci
npm run build
npm run examples:build
npm test
@@ -182,15 +219,18 @@ jobs:
steps:
- uses: actions/setup-node@v6
with:
- node-version: "20"
+ node-version: "22"
- name: Create test project and install from git
shell: bash
+ env:
+ HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
+ HEAD_REF: ${{ github.head_ref || github.ref_name }}
run: |
mkdir test-project
cd test-project
npm init -y
# Install from the PR branch (use head repo for fork PRs)
- npm install "git+https://github.com/${{ github.event.pull_request.head.repo.full_name || github.repository }}#${{ github.head_ref || github.ref_name }}"
+ npm install "git+https://github.com/${HEAD_REPO}#${HEAD_REF}"
# Verify the package is usable (ESM import)
node --input-type=module -e "import { App } from '@modelcontextprotocol/ext-apps'; console.log('Import successful:', typeof App)"
diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml
new file mode 100644
index 0000000..3a170cd
--- /dev/null
+++ b/.github/workflows/docs-preview.yml
@@ -0,0 +1,65 @@
+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 f6875a3..a046c29 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -16,8 +16,9 @@ jobs:
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v6
with:
- node-version: "20"
- - run: npm install
+ node-version: "22"
+ cache: npm
+ - run: npm ci
- run: npm run build
- run: npm run docs
- uses: peaceiris/actions-gh-pages@v4
@@ -26,3 +27,4 @@ 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 d969585..ed36c15 100644
--- a/.github/workflows/npm-publish.yml
+++ b/.github/workflows/npm-publish.yml
@@ -42,6 +42,10 @@ jobs:
- 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'
@@ -88,7 +92,7 @@ jobs:
runs-on: ubuntu-latest
if: github.event_name == 'release'
environment: Release
- needs: [publish]
+ needs: [build, test]
permissions:
contents: read
@@ -108,6 +112,7 @@ jobs:
- cohort-heatmap-server
- customer-segmentation-server
- debug-server
+ - lazy-auth-server
- map-server
- pdf-server
- scenario-modeler-server
@@ -134,8 +139,18 @@ 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
+ run: npm publish --workspace examples/${{ matrix.example }} --provenance --access public ${{ steps.npm-tag.outputs.tag }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_SECRET }}
@@ -143,7 +158,7 @@ jobs:
runs-on: ubuntu-latest
if: github.event_name == 'release'
environment: Release
- needs: [publish-examples]
+ needs: [build, test]
permissions:
contents: write
@@ -159,12 +174,8 @@ jobs:
cache: npm
- run: npm ci
- - name: Build pdf-server
- run: npm run build --workspace examples/pdf-server
-
- - name: Pack MCPB bundle
- run: npx -y @anthropic-ai/mcpb pack
- working-directory: examples/pdf-server
+ - 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
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 5f1d3ac..478c5b8 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -34,6 +34,7 @@ jobs:
./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 be7cd8b..182f01a 100644
--- a/.github/workflows/update-snapshots.yml
+++ b/.github/workflows/update-snapshots.yml
@@ -16,10 +16,14 @@ permissions:
jobs:
update-snapshots:
- # Run on workflow_dispatch OR when someone comments "/update-snapshots" on a PR
+ # Run on workflow_dispatch OR when a maintainer comments "/update-snapshots" on a PR.
+ # author_association check prevents arbitrary commenters from triggering a job with
+ # contents:write that pushes a [skip ci] commit to the PR branch.
if: >
github.event_name == 'workflow_dispatch' ||
- (github.event.issue.pull_request && contains(github.event.comment.body, '/update-snapshots'))
+ (github.event.issue.pull_request &&
+ contains(github.event.comment.body, '/update-snapshots') &&
+ contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association))
runs-on: ubuntu-latest
steps:
- name: Get PR branch
@@ -59,7 +63,8 @@ jobs:
- uses: actions/setup-node@v6
with:
- node-version: "20"
+ node-version: "22"
+ cache: npm
- uses: astral-sh/setup-uv@v7
diff --git a/.gitignore b/.gitignore
index abb7660..5b5486b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,4 +15,6 @@ __pycache__/
*.pyc
# MCPB bundles (built artifacts)
-*.mcpb
\ No newline at end of file
+*.mcpb
+.mcpb-stage/
+.mcpb-smoke/
\ No newline at end of file
diff --git a/.husky/pre-commit b/.husky/pre-commit
index 942f085..ae5b3c7 100644
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -14,8 +14,16 @@ 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
-# Stage any files reformatted by prettier (generated files, source, docs, etc.)
-git diff --name-only --diff-filter=M | xargs -r git add
+# Re-stage originally-staged files (prettier may have reformatted them)
+echo "$STAGED" | xargs -r git add
+
+# Also stage generated files (regenerated by build:all from spec.types.ts)
+git add src/generated/
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..214c29d
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+registry=https://registry.npmjs.org/
diff --git a/.prettierignore b/.prettierignore
index 6d5fc6f..c51df7b 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -5,4 +5,5 @@ examples/basic-server-*/**/*.tsx
examples/quickstart/**/*.ts
**/vendor/**
**/.build/**
+**/.claude/**
SKILL.md
diff --git a/AGENTS.md b/AGENTS.md
index 41997da..219b947 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -39,7 +39,10 @@ npm test
# Check JSDoc comment syntax and `{@link}` references
npm exec typedoc -- --treatValidationWarningsAsErrors --emit none
-# Regenerate package-lock.json (especially on setups w/ custom npm registry)
+# Regenerate package-lock.json
+# Note: repo .npmrc pins registry to npmjs.org, so a plain `npm i` is safe even
+# if your global npm config points elsewhere. The Docker step below is optional
+# โ it locks linux-amd64 optionalDependencies (sharp, rollup, bun) for CI.
rm -fR package-lock.json node_modules && \
docker run --rm -it --platform linux/amd64 -v $PWD:/src:rw -w /src node:latest npm i && \
rm -fR node_modules && \
@@ -57,8 +60,8 @@ rm -fR package-lock.json node_modules && \
### Key Source Files
-- `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/app.ts` - `App` subclasses `Protocol` from `@modelcontextprotocol/client`, handles View initialization, tool calls, and messaging
+- `src/app-bridge.ts` - `AppBridge` subclasses `Protocol` from `@modelcontextprotocol/client` for the iframe channel and proxies through a separate outer `Client`
- `src/server/index.ts` - Helpers for MCP servers to register tools/resources with UI metadata
- `src/types.ts` - Protocol types re-exported from `spec.types.ts` and Zod schemas from `generated/schema.ts` (auto-generated during build)
- `src/message-transport.ts` - `PostMessageTransport` for iframe communication
@@ -67,12 +70,12 @@ rm -fR package-lock.json node_modules && \
### Protocol Flow
```
-View (App) <--PostMessageTransport--> Host (AppBridge) <--MCP Client--> MCP Server
+View (App/Protocol) <--PostMessageTransport--> Host (AppBridge/Protocol) <--separate MCP Client--> MCP Server
```
1. Host creates iframe with view HTML
2. View creates `App` instance and calls `connect()` with `PostMessageTransport`
-3. View sends `ui/initialize` request, receives host capabilities and context
+3. View sends `ui/initialize` (the only handshake on the iframe channel) and receives authoritative Apps capabilities and context
4. Host sends `sendToolInput()` with tool arguments after initialization
5. View can call server tools via `app.callServerTool()` or send messages via `app.sendMessage()`
6. Host sends `sendToolResult()` when tool execution completes
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3793622..e28dcd1 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -25,6 +25,15 @@ 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
@@ -40,6 +49,408 @@ 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
@@ -91,7 +502,7 @@ Alternatively, use the [workflow dispatch](https://github.com/modelcontextprotoc
## Code of Conduct
-This project follows our [Code of Conduct](CODE_OF_CONDUCT.md). Please review it before contributing.
+This project follows our [Code of Conduct](https://github.com/modelcontextprotocol/.github/blob/main/CODE_OF_CONDUCT.md). Please review it before contributing.
## Reporting Issues
@@ -128,40 +539,17 @@ Before publishing releases, ensure the following are configured:
### Publishing a Release
-Releases are published automatically via GitHub Actions when a GitHub Release is created.
-
-#### Steps to publish:
-
-1. **Update the version** in `package.json`:
+1. **Bump the version** across the root and all workspace packages:
```bash
- # For a regular release
- npm version patch # or minor, or major
-
- # For a beta release
- npm version prerelease --preid=beta
+ npm run bump -- minor # or: patch | major | prerelease --preid=beta | 1.7.0
```
-2. **Commit the version bump** (if not done by `npm version`):
+ Commit and open a PR with a grouped changelog in the body (see prior `chore: bump โฆ` PRs for the format).
- ```bash
- git add package.json
- git commit -m "Bump version to X.Y.Z"
- git push origin main
- ```
+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.
-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
+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.
#### npm Tags
@@ -185,7 +573,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/pkg-pr-new/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/stackblitz-labs/pkg.pr.new). Check the PR comments or workflow logs for the install command.
---
diff --git a/README.md b/README.md
index 9427f4b..ab4004d 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,19 @@
-
-
+
+
+
+
+
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
+ Quickstart
ยท
- API Docs
+ API Docs
ยท
Spec
ยท
@@ -24,7 +27,7 @@
-
+
@@ -86,6 +89,8 @@ it will guide you through the rest.
+
+
> [!NOTE]
@@ -117,12 +122,36 @@ resources:
## 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
+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://modelcontextprotocol.github.io/ext-apps/api/documents/Quickstart.html)
+[Quickstart Guide](https://apps.extensions.modelcontextprotocol.io/api/documents/quickstart.html)
to build your first MCP App.
## Using the SDK
@@ -133,10 +162,10 @@ UI metadata.
| Package | Purpose | Docs |
| ------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
-| `@modelcontextprotocol/ext-apps` | Build interactive Views (App class, PostMessageTransport) | [API Docs โ](https://modelcontextprotocol.github.io/ext-apps/api/modules/app.html) |
-| `@modelcontextprotocol/ext-apps/react` | React hooks for Views (useApp, useHostStyles, etc.) | [API Docs โ](https://modelcontextprotocol.github.io/ext-apps/api/modules/_modelcontextprotocol_ext-apps_react.html) |
-| `@modelcontextprotocol/ext-apps/app-bridge` | Embed and communicate with Views in your chat client | [API Docs โ](https://modelcontextprotocol.github.io/ext-apps/api/modules/app-bridge.html) |
-| `@modelcontextprotocol/ext-apps/server` | Register tools and resources on your MCP server | [API Docs โ](https://modelcontextprotocol.github.io/ext-apps/api/modules/server.html) |
+| `@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)
@@ -243,8 +272,8 @@ point your client at a local build:
## Resources
-- [Quickstart Guide](https://modelcontextprotocol.github.io/ext-apps/api/documents/Quickstart.html)
-- [API Documentation](https://modelcontextprotocol.github.io/ext-apps/api/)
+- [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))
- [SEP-1865 Discussion](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865)
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..5029242
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,21 @@
+# 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 0e53418..61361e6 100644
--- a/build.bun.ts
+++ b/build.bun.ts
@@ -1,9 +1,18 @@
#!/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
@@ -25,10 +34,20 @@ 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: ["@modelcontextprotocol/sdk"],
+ external: PEER_EXTERNALS,
}),
buildJs("src/app.ts", {
outdir: "dist/src",
@@ -36,19 +55,19 @@ await Promise.all([
}),
buildJs("src/app-bridge.ts", {
outdir: "dist/src",
- external: ["@modelcontextprotocol/sdk"],
+ external: PEER_EXTERNALS,
}),
buildJs("src/react/index.tsx", {
outdir: "dist/src/react",
- external: ["react", "react-dom", "@modelcontextprotocol/sdk"],
+ external: ["react", "react-dom", ...PEER_EXTERNALS],
}),
buildJs("src/react/index.tsx", {
outdir: "dist/src/react",
- external: ["react", "react-dom", "@modelcontextprotocol/sdk"],
+ external: ["react", "react-dom"],
naming: { entry: "react-with-deps.js" },
}),
buildJs("src/server/index.ts", {
outdir: "dist/src/server",
- external: ["@modelcontextprotocol/sdk"],
+ external: PEER_EXTERNALS,
}),
]);
diff --git a/docs/agent-skills.md b/docs/agent-skills.md
index 36e1f98..d27ad71 100644
--- a/docs/agent-skills.md
+++ b/docs/agent-skills.md
@@ -1,5 +1,7 @@
---
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
diff --git a/docs/authorization.md b/docs/authorization.md
new file mode 100644
index 0000000..df7adc6
--- /dev/null
+++ b/docs/authorization.md
@@ -0,0 +1,211 @@
+---
+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}
+
Manage
+ {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
new file mode 100644
index 0000000..8978ab6
--- /dev/null
+++ b/docs/csp-cors.md
@@ -0,0 +1,64 @@
+---
+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
new file mode 100644
index 0000000..2e10abc
Binary files /dev/null and b/docs/favicons/apple-touch-icon.png differ
diff --git a/docs/favicons/favicon-16x16.png b/docs/favicons/favicon-16x16.png
new file mode 100644
index 0000000..1548f7e
Binary files /dev/null and b/docs/favicons/favicon-16x16.png differ
diff --git a/docs/favicons/favicon-32x32.png b/docs/favicons/favicon-32x32.png
new file mode 100644
index 0000000..88f78f6
Binary files /dev/null and b/docs/favicons/favicon-32x32.png differ
diff --git a/docs/favicons/favicon-dark-16x16.png b/docs/favicons/favicon-dark-16x16.png
new file mode 100644
index 0000000..1548f7e
Binary files /dev/null and b/docs/favicons/favicon-dark-16x16.png differ
diff --git a/docs/favicons/favicon-dark-32x32.png b/docs/favicons/favicon-dark-32x32.png
new file mode 100644
index 0000000..b861b12
Binary files /dev/null and b/docs/favicons/favicon-dark-32x32.png differ
diff --git a/docs/favicons/favicon-dark.ico b/docs/favicons/favicon-dark.ico
new file mode 100644
index 0000000..fd1f24d
Binary files /dev/null and b/docs/favicons/favicon-dark.ico differ
diff --git a/docs/favicons/favicon.ico b/docs/favicons/favicon.ico
new file mode 100644
index 0000000..b92e162
Binary files /dev/null and b/docs/favicons/favicon.ico differ
diff --git a/docs/mcp-theme.css b/docs/mcp-theme.css
new file mode 100644
index 0000000..d786e55
--- /dev/null
+++ b/docs/mcp-theme.css
@@ -0,0 +1,216 @@
+/*
+ * 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
new file mode 100644
index 0000000..f9341d0
--- /dev/null
+++ b/docs/migrate-to-2.md
@@ -0,0 +1,130 @@
+---
+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 6f5a1c3..22d5d9f 100644
--- a/docs/migrate_from_openai_apps.md
+++ b/docs/migrate_from_openai_apps.md
@@ -1,5 +1,7 @@
---
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
@@ -64,7 +66,7 @@ The server-side changes involve updating metadata structure and using helper fun
#### Before (OpenAI)
```typescript
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
function createServer() {
@@ -76,7 +78,7 @@ function createServer() {
{
title: "Shopping Cart",
description: "Display the user's shopping cart",
- inputSchema: { userId: z.string() },
+ inputSchema: z.object({ userId: z.string() }),
annotations: { readOnlyHint: true },
_meta: {
"openai/outputTemplate": "ui://view/cart.html",
@@ -124,7 +126,7 @@ function createServer() {
#### After (MCP Apps)
```typescript
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { McpServer } from "@modelcontextprotocol/server";
import {
registerAppTool,
registerAppResource,
@@ -142,7 +144,7 @@ function createServer() {
{
title: "Shopping Cart",
description: "Display the user's shopping cart",
- inputSchema: { userId: z.string() },
+ inputSchema: z.object({ userId: z.string() }),
annotations: { readOnlyHint: true },
_meta: { ui: { resourceUri: "ui://view/cart.html" } },
},
diff --git a/docs/overview.md b/docs/overview.md
index 4de6a68..8eda95d 100644
--- a/docs/overview.md
+++ b/docs/overview.md
@@ -1,5 +1,7 @@
---
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
@@ -54,7 +56,11 @@ 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 acts as an MCP client, the Host acts as a proxy, and the Server is a standard MCP server.
+The View's `App` and the Host's `AppBridge` both subclass the `Protocol` class
+exported by `@modelcontextprotocol/client` for the iframe channel. A separate outer `Client` connects
+the Host to the actual MCP Server. Keeping those two connections separate
+preserves the Apps-only iframe handshake and prevents iframe capabilities from
+leaking into the server connection.
## Lifecycle
@@ -91,7 +97,7 @@ 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` and receives host context (theme, capabilities, container dimensions). This handshake ensures the View is ready before receiving data.
+2. **Initialization** โ When a UI tool is called, the Host renders the iframe. The View sends `ui/initialize` (the only handshake on the iframe channel) and receives host context (theme, capabilities, container dimensions). `ui/initialize` is authoritative for Apps capabilities and identity, and `ui/notifications/initialized` is the public View-ready signal.
3. **Data delivery** โ The Host sends tool arguments and, once available, tool results to the View. Results include both `content` (text for the model's context) and optionally `structuredContent` (data optimized for UI rendering). This separation lets servers provide rich data to the UI without bloating the model's context.
4. **Interactive phase** โ The user interacts with the View. The View can call tools, send messages, or update context.
5. **Teardown** โ Before unmounting, the Host notifies the View so it can save state or release resources.
diff --git a/docs/patterns.md b/docs/patterns.md
index 3a6302c..884de26 100644
--- a/docs/patterns.md
+++ b/docs/patterns.md
@@ -1,5 +1,7 @@
---
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
@@ -17,7 +19,7 @@ registerAppTool(
"update-quantity",
{
description: "Update item quantity in cart",
- inputSchema: { itemId: z.string(), quantity: z.number() },
+ inputSchema: z.object({ itemId: z.string(), quantity: z.number() }),
_meta: {
ui: {
resourceUri: "ui://shop/cart.html",
@@ -125,14 +127,14 @@ registerAppTool(
{
title: "Read Data Bytes",
description: "Load binary data in chunks",
- inputSchema: {
+ inputSchema: z.object({
id: z.string().describe("Resource identifier"),
offset: z.number().min(0).default(0).describe("Byte offset"),
byteCount: z
.number()
.default(MAX_CHUNK_BYTES)
.describe("Bytes to read"),
- },
+ }),
outputSchema: DataChunkSchema,
// Hidden from model - only callable by the App
_meta: { ui: { visibility: ["app"] } },
@@ -279,10 +281,7 @@ server.registerResource(
```ts source="./patterns.tsx#binaryBlobResourceClient"
-const result = await app.request(
- { method: "resources/read", params: { uri: `video://${videoId}` } },
- ReadResourceResultSchema,
-);
+const result = await app.readServerResource({ uri: `video://${videoId}` });
const content = result.contents[0];
if (!content || !("blob" in content)) {
@@ -298,62 +297,7 @@ videoEl.src = `data:${content.mimeType!};base64,${content.blob}`;
## Configuring CSP and 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`).
+See the dedicated [CSP & CORS](./csp-cors.md) guide in the Security section.
## Adapting to host context (theme, styling, fonts, and safe areas)
diff --git a/docs/patterns.tsx b/docs/patterns.tsx
index 6b87692..bb9eaa4 100644
--- a/docs/patterns.tsx
+++ b/docs/patterns.tsx
@@ -17,16 +17,12 @@ import { randomUUID } from "node:crypto";
import type {
CallToolResult,
ReadResourceResult,
-} from "@modelcontextprotocol/sdk/types.js";
-import { ReadResourceResultSchema } from "@modelcontextprotocol/sdk/types.js";
+} from "@modelcontextprotocol/server";
import type { McpUiHostContext } from "../src/types.js";
import { useEffect, useState } from "react";
import { useApp } from "../src/react/index.js";
import { registerAppTool } from "../src/server/index.js";
-import {
- McpServer,
- ResourceTemplate,
-} from "@modelcontextprotocol/sdk/server/mcp.js";
+import { McpServer, ResourceTemplate } from "@modelcontextprotocol/server";
import { z } from "zod";
/**
@@ -117,14 +113,14 @@ function chunkedDataServer(server: McpServer) {
{
title: "Read Data Bytes",
description: "Load binary data in chunks",
- inputSchema: {
+ inputSchema: z.object({
id: z.string().describe("Resource identifier"),
offset: z.number().min(0).default(0).describe("Byte offset"),
byteCount: z
.number()
.default(MAX_CHUNK_BYTES)
.describe("Bytes to read"),
- },
+ }),
outputSchema: DataChunkSchema,
// Hidden from model - only callable by the App
_meta: { ui: { visibility: ["app"] } },
@@ -252,10 +248,7 @@ function binaryBlobResourceServer(
*/
async function binaryBlobResourceClient(app: App, videoId: string) {
//#region binaryBlobResourceClient
- const result = await app.request(
- { method: "resources/read", params: { uri: `video://${videoId}` } },
- ReadResourceResultSchema,
- );
+ const result = await app.readServerResource({ uri: `video://${videoId}` });
const content = result.contents[0];
if (!content || !("blob" in content)) {
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 3fb1198..a021912 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -1,5 +1,7 @@
---
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
@@ -19,7 +21,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 18+.
+You'll also need Node.js 20+.
## 1. Set up the project
@@ -35,7 +37,7 @@ Install the dependencies you'll need:
```bash
npm init -y
-npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk express cors
+npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/client@^2.0.0 @modelcontextprotocol/server@^2.0.0 @modelcontextprotocol/node@^2.0.0 @modelcontextprotocol/express@^2.0.0 zod@^4.2.0 express cors
npm install -D typescript vite vite-plugin-singlefile @types/express @types/cors @types/node tsx concurrently cross-env
```
@@ -44,11 +46,11 @@ 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 "cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch" "tsx watch main.ts"'
+npm pkg set scripts.start='concurrently --raw "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 ""cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch"" ""tsx watch main.ts"""`.
+> 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 :
@@ -57,6 +59,7 @@ npm pkg set scripts.start='concurrently "cross-env NODE_ENV=development INPUT=mc
```json source="../examples/quickstart/tsconfig.json"
{
"compilerOptions": {
+ "types": ["node", "vite/client"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
@@ -85,6 +88,7 @@ npm pkg set scripts.start='concurrently "cross-env NODE_ENV=development INPUT=mc
```json source="../examples/quickstart/tsconfig.server.json"
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
@@ -111,7 +115,7 @@ npm pkg set scripts.start='concurrently "cross-env NODE_ENV=development INPUT=mc
```ts source="../examples/quickstart/vite.config.ts"
-import { defineConfig } from "vite";
+import { createLogger, defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
const INPUT = process.env.INPUT;
@@ -121,7 +125,14 @@ 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,
@@ -169,9 +180,10 @@ import {
registerAppTool,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { McpServer } from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
+import { z } from "zod";
const DIST_DIR = path.join(import.meta.dirname, "dist");
@@ -196,7 +208,7 @@ export function createServer(): McpServer {
{
title: "Get Time",
description: "Returns the current server time.",
- inputSchema: {},
+ inputSchema: z.object({}),
_meta: { ui: { resourceUri } }, // Links this tool to its UI resource
},
async () => {
@@ -231,10 +243,10 @@ export function createServer(): McpServer {
```ts source="../examples/quickstart/main.ts"
-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 { createMcpExpressApp } from "@modelcontextprotocol/express";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
@@ -254,7 +266,7 @@ export async function startStreamableHTTPServer(
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
@@ -472,4 +484,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://modelcontextprotocol.github.io/ext-apps/api/)
+- **API reference**: See the full [API documentation](https://apps.extensions.modelcontextprotocol.io/api/)
diff --git a/docs/testing-mcp-apps.md b/docs/testing-mcp-apps.md
index 6bef13e..a8eb333 100644
--- a/docs/testing-mcp-apps.md
+++ b/docs/testing-mcp-apps.md
@@ -1,5 +1,7 @@
---
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
@@ -26,6 +28,10 @@ The [`basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/ex
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
diff --git a/examples/basic-host/package.json b/examples/basic-host/package.json
index cf8ff4d..cefe9d5 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": "1.1.2",
+ "version": "2.0.0",
"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/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
@@ -29,7 +29,6 @@
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"vite": "^6.0.0",
- "vite-plugin-singlefile": "^2.3.0",
- "vitest": "^3.2.4"
+ "vite-plugin-singlefile": "^2.3.0"
}
}
diff --git a/examples/basic-host/src/implementation.ts b/examples/basic-host/src/implementation.ts
index 36ecee0..5f680ff 100644
--- a/examples/basic-host/src/implementation.ts
+++ b/examples/basic-host/src/implementation.ts
@@ -1,8 +1,12 @@
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 } 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, Resource, Tool } from "@modelcontextprotocol/sdk/types.js";
+import {
+ Client,
+ SSEClientTransport,
+ StreamableHTTPClientTransport,
+ type CallToolResult,
+ type Resource,
+ type Tool,
+} from "@modelcontextprotocol/client";
import { getTheme, onThemeChange } from "./theme";
import { HOST_STYLE_VARIABLES } from "./host-styles";
@@ -306,6 +310,28 @@ export function newAppBridge(
appBridge.sendHostContextChange({ theme: newTheme });
});
+ // Per spec, the host SHOULD notify the view when container dimensions
+ // change. A ResizeObserver on the iframe covers window resize, layout
+ // shifts, and the inlineโfullscreen panel toggle (which React applies
+ // a tick after onrequestdisplaymode returns โ sending containerDimensions
+ // alongside displayMode there would race the layout). Height stays
+ // flexible (maxHeight) so the view can keep driving it via sendSizeChanged.
+ const iframeResizeObserver = new ResizeObserver(([entry]) => {
+ const width = Math.round(entry.contentRect.width);
+ if (width > 0) {
+ appBridge.sendHostContextChange({
+ containerDimensions: { width, maxHeight: 6000 },
+ });
+ }
+ });
+ iframeResizeObserver.observe(iframe);
+ // AppBridge inherits Protocol's onclose hook โ chain disposal there.
+ const prevOnclose = appBridge.onclose;
+ appBridge.onclose = () => {
+ iframeResizeObserver.disconnect();
+ prevOnclose?.();
+ };
+
// Register all handlers before calling connect(). The view can start
// 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.tsx b/examples/basic-host/src/index.tsx
index 3d488a7..00c0d52 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/sdk/types.js";
+import type { Tool } from "@modelcontextprotocol/client";
import { Component, type ErrorInfo, type ReactNode, StrictMode, Suspense, use, useEffect, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo, type ModelContext, type AppMessage } from "./implementation";
diff --git a/examples/basic-host/src/sandbox.ts b/examples/basic-host/src/sandbox.ts
index 1caf1c5..6f1dd93 100644
--- a/examples/basic-host/src/sandbox.ts
+++ b/examples/basic-host/src/sandbox.ts
@@ -1,5 +1,5 @@
-import type { McpUiSandboxProxyReadyNotification, McpUiSandboxResourceReadyNotification } from "../../../dist/src/types";
-import { buildAllowAttribute } from "../../../dist/src/app-bridge";
+import type { McpUiSandboxProxyReadyNotification, McpUiSandboxResourceReadyNotification } from "@modelcontextprotocol/ext-apps/app-bridge";
+import { buildAllowAttribute } from "@modelcontextprotocol/ext-apps/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 9d7c3bc..3c143ca 100644
--- a/examples/basic-host/tsconfig.json
+++ b/examples/basic-host/tsconfig.json
@@ -1,5 +1,6 @@
{
"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 1f929f1..6a75ddb 100644
--- a/examples/basic-server-preact/README.md
+++ b/examples/basic-server-preact/README.md
@@ -47,8 +47,8 @@ To test local modifications, use this configuration (replace `~/code/ext-apps` w
## Overview
- Tool registration with a linked UI resource
-- 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)
+- 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)
## Key Files
@@ -70,4 +70,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://modelcontextprotocol.github.io/ext-apps/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://apps.extensions.modelcontextprotocol.io/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 7642632..ffdff52 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 { 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 { createMcpExpressApp } from "@modelcontextprotocol/express";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
@@ -27,7 +27,7 @@ export async function startStreamableHTTPServer(
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
diff --git a/examples/basic-server-preact/package.json b/examples/basic-server-preact/package.json
index 166b75f..60aa40d 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": "1.1.2",
+ "version": "2.0.0",
"type": "module",
"description": "Basic MCP App Server example using Preact",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/basic-server-preact"
},
"license": "MIT",
@@ -17,17 +17,22 @@
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"",
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
"serve": "bun --watch main.ts",
+ "serve:stdio": "bun main.ts --stdio",
"start": "cross-env NODE_ENV=development npm run build && npm run serve",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
"dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"",
"prepublishOnly": "npm run build"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"preact": "^10.0.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@preact/preset-vite": "^2.0.0",
diff --git a/examples/basic-server-preact/server.ts b/examples/basic-server-preact/server.ts
index bf14ea5..dfec94a 100644
--- a/examples/basic-server-preact/server.ts
+++ b/examples/basic-server-preact/server.ts
@@ -1,8 +1,12 @@
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
+import { z } from "zod";
// Works both from source (server.ts) and compiled (dist/server.js)
const DIST_DIR = import.meta.filename.endsWith(".ts")
? path.join(import.meta.dirname, "dist")
@@ -28,7 +32,7 @@ export function createServer(): McpServer {
{
title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.",
- inputSchema: {},
+ inputSchema: z.object({}),
_meta: { ui: { resourceUri } }, // Links this tool to its UI resource
},
async (): Promise => {
diff --git a/examples/basic-server-preact/src/mcp-app.tsx b/examples/basic-server-preact/src/mcp-app.tsx
index a8bdc68..351a8ec 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/sdk/types.js";
+import type { CallToolResult } from "@modelcontextprotocol/client";
import { useCallback, useEffect, useState } from "preact/hooks";
import { render } from "preact";
import styles from "./mcp-app.module.css";
diff --git a/examples/basic-server-preact/tsconfig.json b/examples/basic-server-preact/tsconfig.json
index 32182d9..9bf9389 100644
--- a/examples/basic-server-preact/tsconfig.json
+++ b/examples/basic-server-preact/tsconfig.json
@@ -1,5 +1,6 @@
{
"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 05ddd8e..27eb28f 100644
--- a/examples/basic-server-preact/tsconfig.server.json
+++ b/examples/basic-server-preact/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"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 07d235f..d2f2e0d 100644
--- a/examples/basic-server-react/README.md
+++ b/examples/basic-server-react/README.md
@@ -49,8 +49,8 @@ To test local modifications, use this configuration (replace `~/code/ext-apps` w
## Overview
- Tool registration with a linked UI resource
-- 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)
+- React UI using the [`useApp()`](https://apps.extensions.modelcontextprotocol.io/api/functions/_modelcontextprotocol_ext-apps_react.useApp.html) hook
+- App communication APIs: [`callServerTool`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#callservertool), [`sendMessage`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendmessage), [`sendLog`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendlog), [`openLink`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#openlink)
## Key Files
@@ -72,4 +72,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://modelcontextprotocol.github.io/ext-apps/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://apps.extensions.modelcontextprotocol.io/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 ec187b6..21c5e18 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/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 { createMcpExpressApp } from "@modelcontextprotocol/express";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
@@ -27,7 +27,7 @@ export async function startStreamableHTTPServer(
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
diff --git a/examples/basic-server-react/package.json b/examples/basic-server-react/package.json
index 0df2201..ea51a8a 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": "1.1.2",
+ "version": "2.0.0",
"type": "module",
"description": "Basic MCP App Server example using React",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/basic-server-react"
},
"license": "MIT",
@@ -27,18 +27,23 @@
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"",
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
"serve": "bun --watch main.ts",
+ "serve:stdio": "bun main.ts --stdio",
"start": "cross-env NODE_ENV=development npm run build && npm run serve",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
"dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"",
"prepublishOnly": "npm run build"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
diff --git a/examples/basic-server-react/server.ts b/examples/basic-server-react/server.ts
index 23f6dda..d77c908 100644
--- a/examples/basic-server-react/server.ts
+++ b/examples/basic-server-react/server.ts
@@ -1,8 +1,12 @@
import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
+import { z } from "zod";
// Works both from source (server.ts) and compiled (dist/server.js)
const DIST_DIR = import.meta.filename.endsWith(".ts")
@@ -29,7 +33,7 @@ export function createServer(): McpServer {
{
title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.",
- inputSchema: {},
+ inputSchema: z.object({}),
_meta: { ui: { resourceUri } }, // Links this tool to its UI resource
},
async (): Promise => {
diff --git a/examples/basic-server-react/src/mcp-app.tsx b/examples/basic-server-react/src/mcp-app.tsx
index 6bb5540..2b5fbef 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/sdk/types.js";
+import type { CallToolResult } from "@modelcontextprotocol/client";
import { StrictMode, useCallback, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import styles from "./mcp-app.module.css";
diff --git a/examples/basic-server-react/tsconfig.json b/examples/basic-server-react/tsconfig.json
index fc3c210..3ecbdad 100644
--- a/examples/basic-server-react/tsconfig.json
+++ b/examples/basic-server-react/tsconfig.json
@@ -1,5 +1,6 @@
{
"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 05ddd8e..27eb28f 100644
--- a/examples/basic-server-react/tsconfig.server.json
+++ b/examples/basic-server-react/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"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 cb12859..43811a2 100644
--- a/examples/basic-server-solid/README.md
+++ b/examples/basic-server-solid/README.md
@@ -47,8 +47,8 @@ To test local modifications, use this configuration (replace `~/code/ext-apps` w
## Overview
- Tool registration with a linked UI resource
-- 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)
+- 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)
## Key Files
@@ -70,4 +70,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://modelcontextprotocol.github.io/ext-apps/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://apps.extensions.modelcontextprotocol.io/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 c8d9de2..095ef8d 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/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 { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
+import { createMcpExpressApp } from "@modelcontextprotocol/express";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
@@ -27,7 +27,7 @@ export async function startStreamableHTTPServer(
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
diff --git a/examples/basic-server-solid/package.json b/examples/basic-server-solid/package.json
index b8115cb..b3ca909 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": "1.1.2",
+ "version": "2.0.0",
"type": "module",
"description": "Basic MCP App Server example using Solid",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/basic-server-solid"
},
"license": "MIT",
@@ -17,17 +17,22 @@
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"",
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
"serve": "bun --watch main.ts",
+ "serve:stdio": "bun main.ts --stdio",
"start": "cross-env NODE_ENV=development npm run build && npm run serve",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
"dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"",
"prepublishOnly": "npm run build"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"solid-js": "1.9.10",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
@@ -38,7 +43,7 @@
"typescript": "^5.9.3",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0",
- "vite-plugin-solid": "^2.0.0"
+ "vite-plugin-solid": "^2.11.12"
},
"types": "dist/server.d.ts",
"exports": {
diff --git a/examples/basic-server-solid/server.ts b/examples/basic-server-solid/server.ts
index 2ed2d93..addb522 100644
--- a/examples/basic-server-solid/server.ts
+++ b/examples/basic-server-solid/server.ts
@@ -1,8 +1,12 @@
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
+import { z } from "zod";
// Works both from source (server.ts) and compiled (dist/server.js)
const DIST_DIR = import.meta.filename.endsWith(".ts")
? path.join(import.meta.dirname, "dist")
@@ -28,7 +32,7 @@ export function createServer(): McpServer {
{
title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.",
- inputSchema: {},
+ inputSchema: z.object({}),
_meta: { ui: { resourceUri } }, // Links this tool to its UI resource
},
async (): Promise => {
diff --git a/examples/basic-server-solid/src/mcp-app.tsx b/examples/basic-server-solid/src/mcp-app.tsx
index 66d47f3..2d78b7b 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/sdk/types.js";
+import type { CallToolResult } from "@modelcontextprotocol/client";
import { createEffect, createSignal, onMount, Show } from "solid-js";
import { render } from "solid-js/web";
import styles from "./mcp-app.module.css";
diff --git a/examples/basic-server-solid/tsconfig.json b/examples/basic-server-solid/tsconfig.json
index 84f0efe..bb13a01 100644
--- a/examples/basic-server-solid/tsconfig.json
+++ b/examples/basic-server-solid/tsconfig.json
@@ -1,5 +1,6 @@
{
"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 05ddd8e..27eb28f 100644
--- a/examples/basic-server-solid/tsconfig.server.json
+++ b/examples/basic-server-solid/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"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 aa548f1..ec6a0d2 100644
--- a/examples/basic-server-svelte/README.md
+++ b/examples/basic-server-svelte/README.md
@@ -47,8 +47,8 @@ To test local modifications, use this configuration (replace `~/code/ext-apps` w
## Overview
- Tool registration with a linked UI resource
-- 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)
+- 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)
## Key Files
@@ -70,4 +70,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://modelcontextprotocol.github.io/ext-apps/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://apps.extensions.modelcontextprotocol.io/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 6c50a25..734ec0c 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 { 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 { createMcpExpressApp } from "@modelcontextprotocol/express";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
@@ -27,7 +27,7 @@ export async function startStreamableHTTPServer(
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
diff --git a/examples/basic-server-svelte/package.json b/examples/basic-server-svelte/package.json
index 2f6a7b6..19fe4c9 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": "1.1.2",
+ "version": "2.0.0",
"type": "module",
"description": "Basic MCP App Server example using Svelte",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/basic-server-svelte"
},
"license": "MIT",
@@ -17,17 +17,22 @@
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"",
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
"serve": "bun --watch main.ts",
+ "serve:stdio": "bun main.ts --stdio",
"start": "cross-env NODE_ENV=development npm run build && npm run serve",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
"dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"",
"prepublishOnly": "npm run build"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"svelte": "^5.0.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"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 da603ca..9414285 100644
--- a/examples/basic-server-svelte/server.ts
+++ b/examples/basic-server-svelte/server.ts
@@ -1,8 +1,12 @@
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
+import { z } from "zod";
// Works both from source (server.ts) and compiled (dist/server.js)
const DIST_DIR = import.meta.filename.endsWith(".ts")
? path.join(import.meta.dirname, "dist")
@@ -28,7 +32,7 @@ export function createServer(): McpServer {
{
title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.",
- inputSchema: {},
+ inputSchema: z.object({}),
_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 fde128d..cabcc31 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/sdk/types.js";
+import type { CallToolResult } from "@modelcontextprotocol/client";
function extractTime(result: CallToolResult): string {
const { text } = result.content?.find((c) => c.type === "text")!;
diff --git a/examples/basic-server-svelte/tsconfig.json b/examples/basic-server-svelte/tsconfig.json
index 5edb66a..4fd3cd7 100644
--- a/examples/basic-server-svelte/tsconfig.json
+++ b/examples/basic-server-svelte/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/basic-server-svelte/tsconfig.server.json b/examples/basic-server-svelte/tsconfig.server.json
index 05ddd8e..27eb28f 100644
--- a/examples/basic-server-svelte/tsconfig.server.json
+++ b/examples/basic-server-svelte/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/examples/basic-server-vanillajs/README.md b/examples/basic-server-vanillajs/README.md
index 5168f20..6445d8c 100644
--- a/examples/basic-server-vanillajs/README.md
+++ b/examples/basic-server-vanillajs/README.md
@@ -47,9 +47,9 @@ To test local modifications, use this configuration (replace `~/code/ext-apps` w
## Overview
- Tool registration with a linked UI resource
-- Vanilla JS UI using the [`App`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html) class directly
-- App communication APIs: [`callServerTool`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#callservertool), [`sendMessage`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendmessage), [`sendLog`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendlog), [`openLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#openlink)
-- Theme integration via [`applyDocumentTheme()`](https://modelcontextprotocol.github.io/ext-apps/api/functions/app.applyDocumentTheme.html), [`applyHostStyleVariables()`](https://modelcontextprotocol.github.io/ext-apps/api/functions/app.applyHostStyleVariables.html), and [`applyHostFonts()`](https://modelcontextprotocol.github.io/ext-apps/api/functions/app.applyHostFonts.html)
+- Vanilla JS UI using the [`App`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html) class directly
+- App communication APIs: [`callServerTool`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#callservertool), [`sendMessage`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendmessage), [`sendLog`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendlog), [`openLink`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#openlink)
+- Theme integration via [`applyDocumentTheme()`](https://apps.extensions.modelcontextprotocol.io/api/functions/app.applyDocumentTheme.html), [`applyHostStyleVariables()`](https://apps.extensions.modelcontextprotocol.io/api/functions/app.applyHostStyleVariables.html), and [`applyHostFonts()`](https://apps.extensions.modelcontextprotocol.io/api/functions/app.applyHostFonts.html)
## Key Files
@@ -71,4 +71,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://modelcontextprotocol.github.io/ext-apps/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://apps.extensions.modelcontextprotocol.io/api/interfaces/app.McpUiResourceCsp.html#resourcedomains) in the UI resource metadata.
diff --git a/examples/basic-server-vanillajs/main.ts b/examples/basic-server-vanillajs/main.ts
index 286fa34..4de7331 100644
--- a/examples/basic-server-vanillajs/main.ts
+++ b/examples/basic-server-vanillajs/main.ts
@@ -4,10 +4,10 @@
* Or: node dist/index.js [--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 { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
+import { createMcpExpressApp } from "@modelcontextprotocol/express";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
@@ -27,7 +27,7 @@ export async function startStreamableHTTPServer(
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
diff --git a/examples/basic-server-vanillajs/package.json b/examples/basic-server-vanillajs/package.json
index 44d3fdb..ebfebbb 100644
--- a/examples/basic-server-vanillajs/package.json
+++ b/examples/basic-server-vanillajs/package.json
@@ -1,11 +1,11 @@
{
"name": "@modelcontextprotocol/server-basic-vanillajs",
- "version": "1.1.2",
+ "version": "2.0.0",
"type": "module",
"description": "Basic MCP App Server example using vanilla JavaScript",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/basic-server-vanillajs"
},
"license": "MIT",
@@ -17,16 +17,21 @@
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"",
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
"serve": "bun --watch main.ts",
+ "serve:stdio": "bun main.ts --stdio",
"start": "cross-env NODE_ENV=development npm run build && npm run serve",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
"dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"",
"prepublishOnly": "npm run build"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
diff --git a/examples/basic-server-vanillajs/server.ts b/examples/basic-server-vanillajs/server.ts
index 5b2daf7..693d861 100644
--- a/examples/basic-server-vanillajs/server.ts
+++ b/examples/basic-server-vanillajs/server.ts
@@ -1,6 +1,9 @@
import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
@@ -29,7 +32,7 @@ export function createServer(): McpServer {
{
title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.",
- inputSchema: {},
+ inputSchema: z.object({}),
outputSchema: z.object({
time: z.string(),
}),
diff --git a/examples/basic-server-vanillajs/src/mcp-app.ts b/examples/basic-server-vanillajs/src/mcp-app.ts
index c3168c2..6b5084a 100644
--- a/examples/basic-server-vanillajs/src/mcp-app.ts
+++ b/examples/basic-server-vanillajs/src/mcp-app.ts
@@ -8,7 +8,7 @@ import {
applyHostStyleVariables,
type McpUiHostContext,
} from "@modelcontextprotocol/ext-apps";
-import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
+import type { CallToolResult } from "@modelcontextprotocol/client";
import "./global.css";
import "./mcp-app.css";
diff --git a/examples/basic-server-vanillajs/tsconfig.json b/examples/basic-server-vanillajs/tsconfig.json
index 535267b..b028ff5 100644
--- a/examples/basic-server-vanillajs/tsconfig.json
+++ b/examples/basic-server-vanillajs/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/basic-server-vanillajs/tsconfig.server.json b/examples/basic-server-vanillajs/tsconfig.server.json
index 05ddd8e..27eb28f 100644
--- a/examples/basic-server-vanillajs/tsconfig.server.json
+++ b/examples/basic-server-vanillajs/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/examples/basic-server-vue/README.md b/examples/basic-server-vue/README.md
index 9f4e89b..c484f5b 100644
--- a/examples/basic-server-vue/README.md
+++ b/examples/basic-server-vue/README.md
@@ -47,8 +47,8 @@ To test local modifications, use this configuration (replace `~/code/ext-apps` w
## Overview
- Tool registration with a linked UI resource
-- Vue 3 UI using the [`App`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html) class
-- App communication APIs: [`callServerTool`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#callservertool), [`sendMessage`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendmessage), [`sendLog`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendlog), [`openLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#openlink)
+- Vue 3 UI using the [`App`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html) class
+- App communication APIs: [`callServerTool`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#callservertool), [`sendMessage`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendmessage), [`sendLog`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendlog), [`openLink`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#openlink)
## Key Files
@@ -70,4 +70,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://modelcontextprotocol.github.io/ext-apps/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://apps.extensions.modelcontextprotocol.io/api/interfaces/app.McpUiResourceCsp.html#resourcedomains) in the UI resource metadata.
diff --git a/examples/basic-server-vue/main.ts b/examples/basic-server-vue/main.ts
index 669a071..d774269 100644
--- a/examples/basic-server-vue/main.ts
+++ b/examples/basic-server-vue/main.ts
@@ -4,10 +4,10 @@
* Or: node dist/index.js [--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 { createMcpExpressApp } from "@modelcontextprotocol/express";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
@@ -27,7 +27,7 @@ export async function startStreamableHTTPServer(
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
diff --git a/examples/basic-server-vue/package.json b/examples/basic-server-vue/package.json
index e77102b..858dd89 100644
--- a/examples/basic-server-vue/package.json
+++ b/examples/basic-server-vue/package.json
@@ -1,11 +1,11 @@
{
"name": "@modelcontextprotocol/server-basic-vue",
- "version": "1.1.2",
+ "version": "2.0.0",
"type": "module",
"description": "Basic MCP App Server example using Vue",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/basic-server-vue"
},
"license": "MIT",
@@ -17,17 +17,22 @@
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"",
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
"serve": "bun --watch main.ts",
+ "serve:stdio": "bun main.ts --stdio",
"start": "cross-env NODE_ENV=development npm run build && npm run serve",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
"dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"",
"prepublishOnly": "npm run build"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"vue": "^3.5.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
diff --git a/examples/basic-server-vue/server.ts b/examples/basic-server-vue/server.ts
index 5aa72de..9adb911 100644
--- a/examples/basic-server-vue/server.ts
+++ b/examples/basic-server-vue/server.ts
@@ -1,8 +1,12 @@
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
+import { z } from "zod";
// Works both from source (server.ts) and compiled (dist/server.js)
const DIST_DIR = import.meta.filename.endsWith(".ts")
? path.join(import.meta.dirname, "dist")
@@ -28,7 +32,7 @@ export function createServer(): McpServer {
{
title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.",
- inputSchema: {},
+ inputSchema: z.object({}),
_meta: { ui: { resourceUri } }, // Links this tool to its UI resource
},
async (): Promise => {
diff --git a/examples/basic-server-vue/src/App.vue b/examples/basic-server-vue/src/App.vue
index 196ad64..a45c262 100644
--- a/examples/basic-server-vue/src/App.vue
+++ b/examples/basic-server-vue/src/App.vue
@@ -7,7 +7,7 @@ import {
applyHostStyleVariables,
type McpUiHostContext,
} from "@modelcontextprotocol/ext-apps";
-import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
+import type { CallToolResult } from "@modelcontextprotocol/client";
function extractTime(result: CallToolResult): string {
const { text } = result.content?.find((c) => c.type === "text")!;
diff --git a/examples/basic-server-vue/tsconfig.json b/examples/basic-server-vue/tsconfig.json
index 5edb66a..4fd3cd7 100644
--- a/examples/basic-server-vue/tsconfig.json
+++ b/examples/basic-server-vue/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/basic-server-vue/tsconfig.server.json b/examples/basic-server-vue/tsconfig.server.json
index 05ddd8e..27eb28f 100644
--- a/examples/basic-server-vue/tsconfig.server.json
+++ b/examples/basic-server-vue/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/examples/budget-allocator-server/README.md b/examples/budget-allocator-server/README.md
index c2a3e0d..d4da76f 100644
--- a/examples/budget-allocator-server/README.md
+++ b/examples/budget-allocator-server/README.md
@@ -4,9 +4,9 @@ An interactive budget allocation tool demonstrating real-time data visualization
diff --git a/examples/budget-allocator-server/main.ts b/examples/budget-allocator-server/main.ts
index cc488f0..02c03cb 100644
--- a/examples/budget-allocator-server/main.ts
+++ b/examples/budget-allocator-server/main.ts
@@ -4,10 +4,10 @@
* Or: node dist/index.js [--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 { createMcpExpressApp } from "@modelcontextprotocol/express";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
@@ -27,7 +27,7 @@ export async function startStreamableHTTPServer(
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
diff --git a/examples/budget-allocator-server/package.json b/examples/budget-allocator-server/package.json
index ed111c4..6e41622 100644
--- a/examples/budget-allocator-server/package.json
+++ b/examples/budget-allocator-server/package.json
@@ -1,11 +1,11 @@
{
"name": "@modelcontextprotocol/server-budget-allocator",
- "version": "1.1.2",
+ "version": "2.0.0",
"type": "module",
"description": "Budget allocator MCP App Server with interactive visualization",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/budget-allocator-server"
},
"license": "MIT",
@@ -17,21 +17,23 @@
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"",
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
"serve:http": "bun --watch main.ts",
- "serve:stdio": "bun server.ts --stdio",
+ "serve:stdio": "bun main.ts --stdio",
"start": "npm run start:http",
"start:http": "cross-env NODE_ENV=development npm run build && npm run serve:http",
- "start:stdio": "cross-env NODE_ENV=development npm run build && npm run serve:stdio",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
"dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve:http\"",
"prepublishOnly": "npm run build",
"serve": "bun --watch main.ts"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"chart.js": "^4.4.0",
"cors": "^2.8.5",
"express": "^5.1.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
diff --git a/examples/budget-allocator-server/server.ts b/examples/budget-allocator-server/server.ts
index e522918..01f5dbf 100755
--- a/examples/budget-allocator-server/server.ts
+++ b/examples/budget-allocator-server/server.ts
@@ -4,11 +4,11 @@
* Provides budget configuration, 24 months of historical allocation data,
* and industry benchmarks by company stage.
*/
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type {
- CallToolResult,
- ReadResourceResult,
-} from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
@@ -269,8 +269,8 @@ export function createServer(): McpServer {
{
title: "Get Budget Data",
description:
- "Returns budget configuration with 24 months of historical allocations and industry benchmarks by company stage",
- inputSchema: {},
+ "Returns budget configuration with 24 months of historical allocations and industry benchmarks by company stage. The widget is interactive and exposes tools for reading/modifying allocations, adjusting budgets, and comparing against industry benchmarks.",
+ inputSchema: z.object({}),
outputSchema: BudgetDataResponseSchema,
_meta: { ui: { resourceUri } },
},
diff --git a/examples/budget-allocator-server/src/mcp-app.ts b/examples/budget-allocator-server/src/mcp-app.ts
index 4e8c62d..a9bae12 100644
--- a/examples/budget-allocator-server/src/mcp-app.ts
+++ b/examples/budget-allocator-server/src/mcp-app.ts
@@ -3,6 +3,7 @@
*/
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import { Chart, registerables } from "chart.js";
+import { z } from "zod";
import "./global.css";
import "./mcp-app.css";
@@ -626,6 +627,295 @@ function handleHostContextChanged(ctx: McpUiHostContext) {
app.onhostcontextchanged = handleHostContextChanged;
+// Register tools for model interaction
+app.registerTool(
+ "get-allocations",
+ {
+ title: "Get Budget Allocations",
+ description:
+ "Get the current budget allocations including total budget, percentages, and amounts per category",
+ },
+ async () => {
+ if (!state.config) {
+ return {
+ content: [
+ { type: "text" as const, text: "Error: Configuration not loaded" },
+ ],
+ isError: true,
+ };
+ }
+
+ const allocations: Record = {};
+ for (const category of state.config.categories) {
+ const percent = state.allocations.get(category.id) ?? 0;
+ allocations[category.id] = {
+ percent,
+ amount: (percent / 100) * state.totalBudget,
+ };
+ }
+
+ const result = {
+ totalBudget: state.totalBudget,
+ currency: state.config.currency,
+ currencySymbol: state.config.currencySymbol,
+ selectedStage: state.selectedStage,
+ allocations,
+ categories: state.config.categories.map((c) => ({
+ id: c.id,
+ name: c.name,
+ color: c.color,
+ })),
+ };
+
+ return {
+ content: [
+ { type: "text" as const, text: JSON.stringify(result, null, 2) },
+ ],
+ structuredContent: result,
+ };
+ },
+);
+
+app.registerTool(
+ "set-allocation",
+ {
+ title: "Set Category Allocation",
+ description: "Set the allocation percentage for a specific budget category",
+ inputSchema: z.object({
+ categoryId: z
+ .string()
+ .describe(
+ "Category ID (e.g., 'marketing', 'engineering', 'operations', 'sales', 'rd')",
+ ),
+ percent: z
+ .number()
+ .min(0)
+ .max(100)
+ .describe("Allocation percentage (0-100)"),
+ }),
+ },
+ async (args) => {
+ if (!state.config) {
+ return {
+ content: [
+ { type: "text" as const, text: "Error: Configuration not loaded" },
+ ],
+ isError: true,
+ };
+ }
+
+ const category = state.config.categories.find(
+ (c) => c.id === args.categoryId,
+ );
+ if (!category) {
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: `Error: Category "${args.categoryId}" not found. Available: ${state.config.categories.map((c) => c.id).join(", ")}`,
+ },
+ ],
+ isError: true,
+ };
+ }
+
+ handleSliderChange(args.categoryId, args.percent);
+
+ // Also update the slider UI
+ const slider = document.querySelector(
+ `.slider-row[data-category-id="${args.categoryId}"] .slider`,
+ ) as HTMLInputElement | null;
+ if (slider) {
+ slider.value = String(args.percent);
+ }
+
+ const amount = (args.percent / 100) * state.totalBudget;
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: `Set ${category.name} allocation to ${args.percent.toFixed(1)}% (${state.config.currencySymbol}${amount.toLocaleString()})`,
+ },
+ ],
+ };
+ },
+);
+
+app.registerTool(
+ "set-total-budget",
+ {
+ title: "Set Total Budget",
+ description: "Set the total budget amount",
+ inputSchema: z.object({
+ amount: z.number().positive().describe("Total budget amount"),
+ }),
+ },
+ async (args) => {
+ if (!state.config) {
+ return {
+ content: [
+ { type: "text" as const, text: "Error: Configuration not loaded" },
+ ],
+ isError: true,
+ };
+ }
+
+ state.totalBudget = args.amount;
+
+ // Update the budget selector if this amount is a preset
+ const budgetSelector = document.getElementById(
+ "budget-selector",
+ ) as HTMLSelectElement | null;
+ if (budgetSelector) {
+ const option = Array.from(budgetSelector.options).find(
+ (opt) => parseInt(opt.value) === args.amount,
+ );
+ if (option) {
+ budgetSelector.value = String(args.amount);
+ }
+ }
+
+ updateAllSliderAmounts();
+ updateStatusBar();
+ updateComparisonSummary();
+
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: `Total budget set to ${state.config.currencySymbol}${args.amount.toLocaleString()}`,
+ },
+ ],
+ };
+ },
+);
+
+app.registerTool(
+ "set-company-stage",
+ {
+ title: "Set Company Stage",
+ description:
+ "Set the company stage for benchmark comparison (e.g., 'Seed', 'Series A', 'Series B', 'Growth')",
+ inputSchema: z.object({
+ stage: z.string().describe("Company stage ID"),
+ }),
+ },
+ async (args) => {
+ if (!state.analytics) {
+ return {
+ content: [
+ { type: "text" as const, text: "Error: Analytics not loaded" },
+ ],
+ isError: true,
+ };
+ }
+
+ if (!state.analytics.stages.includes(args.stage)) {
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: `Error: Stage "${args.stage}" not found. Available: ${state.analytics.stages.join(", ")}`,
+ },
+ ],
+ isError: true,
+ };
+ }
+
+ state.selectedStage = args.stage;
+
+ // Update the stage selector UI
+ const stageSelector = document.getElementById(
+ "stage-selector",
+ ) as HTMLSelectElement | null;
+ if (stageSelector) {
+ stageSelector.value = args.stage;
+ }
+
+ // Update all badges and summary
+ if (state.config) {
+ for (const category of state.config.categories) {
+ updatePercentileBadge(category.id);
+ }
+ updateComparisonSummary();
+ }
+
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: `Company stage set to "${args.stage}"`,
+ },
+ ],
+ };
+ },
+);
+
+app.registerTool(
+ "get-benchmark-comparison",
+ {
+ title: "Get Benchmark Comparison",
+ description:
+ "Compare current allocations against industry benchmarks for the selected stage",
+ },
+ async () => {
+ if (!state.config || !state.analytics) {
+ return {
+ content: [{ type: "text" as const, text: "Error: Data not loaded" }],
+ isError: true,
+ };
+ }
+
+ const benchmark = state.analytics.benchmarks.find(
+ (b) => b.stage === state.selectedStage,
+ );
+ if (!benchmark) {
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: `Error: No benchmark data for stage "${state.selectedStage}"`,
+ },
+ ],
+ isError: true,
+ };
+ }
+
+ const comparison: Record<
+ string,
+ { current: number; p25: number; p50: number; p75: number; status: string }
+ > = {};
+
+ for (const category of state.config.categories) {
+ const current = state.allocations.get(category.id) ?? 0;
+ const benchmarkData = benchmark.categoryBenchmarks[category.id];
+ let status = "within range";
+ if (current < benchmarkData.p25) status = "below p25";
+ else if (current > benchmarkData.p75) status = "above p75";
+
+ comparison[category.id] = {
+ current,
+ p25: benchmarkData.p25,
+ p50: benchmarkData.p50,
+ p75: benchmarkData.p75,
+ status,
+ };
+ }
+
+ const result = {
+ stage: state.selectedStage,
+ comparison,
+ };
+
+ return {
+ content: [
+ { type: "text" as const, text: JSON.stringify(result, null, 2) },
+ ],
+ structuredContent: result,
+ };
+ },
+);
+
// Handle theme changes
window
.matchMedia("(prefers-color-scheme: dark)")
diff --git a/examples/budget-allocator-server/tsconfig.json b/examples/budget-allocator-server/tsconfig.json
index 535267b..b028ff5 100644
--- a/examples/budget-allocator-server/tsconfig.json
+++ b/examples/budget-allocator-server/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/budget-allocator-server/tsconfig.server.json b/examples/budget-allocator-server/tsconfig.server.json
index 05ddd8e..27eb28f 100644
--- a/examples/budget-allocator-server/tsconfig.server.json
+++ b/examples/budget-allocator-server/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/examples/cohort-heatmap-server/README.md b/examples/cohort-heatmap-server/README.md
index e8a948b..c011933 100644
--- a/examples/cohort-heatmap-server/README.md
+++ b/examples/cohort-heatmap-server/README.md
@@ -4,9 +4,9 @@ A demo MCP App that displays cohort retention data as an interactive heatmap, sh
diff --git a/examples/cohort-heatmap-server/main.ts b/examples/cohort-heatmap-server/main.ts
index d59903b..8b33603 100644
--- a/examples/cohort-heatmap-server/main.ts
+++ b/examples/cohort-heatmap-server/main.ts
@@ -4,10 +4,10 @@
* Or: node dist/index.js [--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 { createMcpExpressApp } from "@modelcontextprotocol/express";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
@@ -27,7 +27,7 @@ export async function startStreamableHTTPServer(
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
diff --git a/examples/cohort-heatmap-server/package.json b/examples/cohort-heatmap-server/package.json
index 21b560b..7e2d2e2 100644
--- a/examples/cohort-heatmap-server/package.json
+++ b/examples/cohort-heatmap-server/package.json
@@ -1,11 +1,11 @@
{
"name": "@modelcontextprotocol/server-cohort-heatmap",
- "version": "1.1.2",
+ "version": "2.0.0",
"type": "module",
"description": "Cohort heatmap MCP App Server for retention analysis",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/cohort-heatmap-server"
},
"license": "MIT",
@@ -17,22 +17,24 @@
"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 server.ts --stdio",
+ "serve:stdio": "bun main.ts --stdio",
"start": "npm run start:http",
"start:http": "cross-env NODE_ENV=development npm run build && npm run serve:http",
- "start:stdio": "cross-env NODE_ENV=development npm run build && npm run serve:stdio",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
"dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve:http\"",
"prepublishOnly": "npm run build",
"serve": "bun --watch main.ts"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
diff --git a/examples/cohort-heatmap-server/server.ts b/examples/cohort-heatmap-server/server.ts
index 6d07144..129e2dc 100644
--- a/examples/cohort-heatmap-server/server.ts
+++ b/examples/cohort-heatmap-server/server.ts
@@ -1,5 +1,7 @@
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
@@ -178,8 +180,8 @@ export function createServer(): McpServer {
title: "Get Cohort Retention Data",
description:
"Returns cohort retention heatmap data showing customer retention over time by signup month",
- inputSchema: GetCohortDataInputSchema.shape,
- outputSchema: CohortDataSchema.shape,
+ inputSchema: GetCohortDataInputSchema,
+ outputSchema: CohortDataSchema,
_meta: { ui: { resourceUri } },
},
async ({ metric, periodType, cohortCount, maxPeriods }) => {
diff --git a/examples/cohort-heatmap-server/tsconfig.json b/examples/cohort-heatmap-server/tsconfig.json
index fc3c210..3ecbdad 100644
--- a/examples/cohort-heatmap-server/tsconfig.json
+++ b/examples/cohort-heatmap-server/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/cohort-heatmap-server/tsconfig.server.json b/examples/cohort-heatmap-server/tsconfig.server.json
index 05ddd8e..27eb28f 100644
--- a/examples/cohort-heatmap-server/tsconfig.server.json
+++ b/examples/cohort-heatmap-server/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/examples/customer-segmentation-server/README.md b/examples/customer-segmentation-server/README.md
index da115a8..8f5cc23 100644
--- a/examples/customer-segmentation-server/README.md
+++ b/examples/customer-segmentation-server/README.md
@@ -4,9 +4,9 @@ A demo MCP App that displays customer data as an interactive scatter/bubble char
diff --git a/examples/customer-segmentation-server/main.ts b/examples/customer-segmentation-server/main.ts
index 130076c..c788757 100644
--- a/examples/customer-segmentation-server/main.ts
+++ b/examples/customer-segmentation-server/main.ts
@@ -4,10 +4,10 @@
* Or: node dist/index.js [--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 { createMcpExpressApp } from "@modelcontextprotocol/express";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
@@ -27,7 +27,7 @@ export async function startStreamableHTTPServer(
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
diff --git a/examples/customer-segmentation-server/package.json b/examples/customer-segmentation-server/package.json
index fb3c6d4..bac3b89 100644
--- a/examples/customer-segmentation-server/package.json
+++ b/examples/customer-segmentation-server/package.json
@@ -1,11 +1,11 @@
{
"name": "@modelcontextprotocol/server-customer-segmentation",
- "version": "1.1.2",
+ "version": "2.0.0",
"type": "module",
"description": "Customer segmentation MCP App Server with filtering",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/customer-segmentation-server"
},
"license": "MIT",
@@ -17,21 +17,23 @@
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"",
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
"serve:http": "bun --watch main.ts",
- "serve:stdio": "bun server.ts --stdio",
+ "serve:stdio": "bun main.ts --stdio",
"start": "npm run start:http",
"start:http": "cross-env NODE_ENV=development npm run build && npm run serve:http",
- "start:stdio": "cross-env NODE_ENV=development npm run build && npm run serve:stdio",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
"dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve:http\"",
"prepublishOnly": "npm run build",
"serve": "bun --watch main.ts"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"chart.js": "^4.4.0",
"cors": "^2.8.5",
"express": "^5.1.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
diff --git a/examples/customer-segmentation-server/server.ts b/examples/customer-segmentation-server/server.ts
index 244415d..7cc1c1f 100644
--- a/examples/customer-segmentation-server/server.ts
+++ b/examples/customer-segmentation-server/server.ts
@@ -1,8 +1,8 @@
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type {
- CallToolResult,
- ReadResourceResult,
-} from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
@@ -100,8 +100,8 @@ export function createServer(): McpServer {
title: "Get Customer Data",
description:
"Returns customer data with segment information for visualization. Optionally filter by segment.",
- inputSchema: GetCustomerDataInputSchema.shape,
- outputSchema: GetCustomerDataOutputSchema.shape,
+ inputSchema: GetCustomerDataInputSchema,
+ outputSchema: GetCustomerDataOutputSchema,
_meta: { ui: { resourceUri } },
},
async ({ segment }): Promise => {
diff --git a/examples/customer-segmentation-server/tsconfig.json b/examples/customer-segmentation-server/tsconfig.json
index 535267b..b028ff5 100644
--- a/examples/customer-segmentation-server/tsconfig.json
+++ b/examples/customer-segmentation-server/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/customer-segmentation-server/tsconfig.server.json b/examples/customer-segmentation-server/tsconfig.server.json
index 05ddd8e..27eb28f 100644
--- a/examples/customer-segmentation-server/tsconfig.server.json
+++ b/examples/customer-segmentation-server/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/examples/debug-server/main.ts b/examples/debug-server/main.ts
index 3c89a97..9fb0825 100644
--- a/examples/debug-server/main.ts
+++ b/examples/debug-server/main.ts
@@ -4,10 +4,10 @@
* Or: node dist/index.js [--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 { createMcpExpressApp } from "@modelcontextprotocol/express";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
@@ -27,7 +27,7 @@ export async function startStreamableHTTPServer(
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
diff --git a/examples/debug-server/package.json b/examples/debug-server/package.json
index ec2a644..e28f4d3 100644
--- a/examples/debug-server/package.json
+++ b/examples/debug-server/package.json
@@ -1,11 +1,11 @@
{
"name": "@modelcontextprotocol/server-debug",
- "version": "1.1.2",
+ "version": "2.0.0",
"type": "module",
"description": "Debug MCP App Server for testing all SDK capabilities",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/debug-server"
},
"license": "MIT",
@@ -27,14 +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\"",
"prepublishOnly": "npm run build"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
- "zod": "^4.1.13"
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
diff --git a/examples/debug-server/server.ts b/examples/debug-server/server.ts
index fd3452a..068f94d 100644
--- a/examples/debug-server/server.ts
+++ b/examples/debug-server/server.ts
@@ -3,11 +3,11 @@ import {
registerAppTool,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type {
- CallToolResult,
- ReadResourceResult,
-} from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import { appendFileSync } from "node:fs";
import path from "node:path";
diff --git a/examples/debug-server/tsconfig.json b/examples/debug-server/tsconfig.json
index 535267b..b028ff5 100644
--- a/examples/debug-server/tsconfig.json
+++ b/examples/debug-server/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/debug-server/tsconfig.server.json b/examples/debug-server/tsconfig.server.json
index 05ddd8e..27eb28f 100644
--- a/examples/debug-server/tsconfig.server.json
+++ b/examples/debug-server/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/examples/integration-server/README.md b/examples/integration-server/README.md
index ffa1d4e..d66ed74 100644
--- a/examples/integration-server/README.md
+++ b/examples/integration-server/README.md
@@ -7,8 +7,8 @@ An MCP App example used for E2E integration testing.
This example demonstrates all App SDK communication APIs and is used by the E2E test suite to verify host-app interactions:
- Tool registration with a linked UI resource
-- React UI using the [`useApp()`](https://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)
+- React UI using the [`useApp()`](https://apps.extensions.modelcontextprotocol.io/api/functions/_modelcontextprotocol_ext-apps_react.useApp.html) hook
+- App communication APIs: [`callServerTool`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#callservertool), [`sendMessage`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendmessage), [`sendLog`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendlog), [`openLink`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#openlink)
## Key Files
diff --git a/examples/integration-server/main.ts b/examples/integration-server/main.ts
index c45787e..9a42842 100644
--- a/examples/integration-server/main.ts
+++ b/examples/integration-server/main.ts
@@ -4,10 +4,10 @@
* Or: node dist/index.js [--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 { createMcpExpressApp } from "@modelcontextprotocol/express";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
@@ -27,7 +27,7 @@ export async function startStreamableHTTPServer(
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
diff --git a/examples/integration-server/package.json b/examples/integration-server/package.json
index 3ef3b3c..7e04f4a 100644
--- a/examples/integration-server/package.json
+++ b/examples/integration-server/package.json
@@ -1,27 +1,30 @@
{
"name": "integration-server",
- "version": "1.1.2",
+ "version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"",
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
"serve:http": "bun --watch main.ts",
- "serve:stdio": "bun server.ts --stdio",
+ "serve:stdio": "bun main.ts --stdio",
"start": "npm run start:http",
"start:http": "cross-env NODE_ENV=development npm run build && npm run serve:http",
- "start:stdio": "cross-env NODE_ENV=development npm run build && npm run serve:stdio",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
"dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve:http\"",
"serve": "bun --watch main.ts"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
diff --git a/examples/integration-server/server.ts b/examples/integration-server/server.ts
index e44689d..346f4f7 100644
--- a/examples/integration-server/server.ts
+++ b/examples/integration-server/server.ts
@@ -3,11 +3,11 @@ import {
registerAppTool,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type {
- CallToolResult,
- ReadResourceResult,
-} from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
@@ -33,7 +33,7 @@ export function createServer(): McpServer {
{
title: "Get Time",
description: "Returns the current server time.",
- inputSchema: {},
+ inputSchema: z.object({}),
outputSchema: z.object({
time: z.string(),
}),
@@ -72,7 +72,7 @@ export function createServer(): McpServer {
);
// Sample downloadable resource โ used to demo ResourceLink in ui/download-file
- server.resource(
+ server.registerResource(
SAMPLE_DOWNLOAD_URI,
SAMPLE_DOWNLOAD_URI,
{
diff --git a/examples/integration-server/src/mcp-app.tsx b/examples/integration-server/src/mcp-app.tsx
index 7f0b18c..281a356 100644
--- a/examples/integration-server/src/mcp-app.tsx
+++ b/examples/integration-server/src/mcp-app.tsx
@@ -3,7 +3,7 @@
*/
import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import { useApp } from "@modelcontextprotocol/ext-apps/react";
-import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
+import type { CallToolResult } from "@modelcontextprotocol/client";
import { StrictMode, useCallback, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import styles from "./mcp-app.module.css";
diff --git a/examples/integration-server/tsconfig.json b/examples/integration-server/tsconfig.json
index fc3c210..3ecbdad 100644
--- a/examples/integration-server/tsconfig.json
+++ b/examples/integration-server/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/integration-server/tsconfig.server.json b/examples/integration-server/tsconfig.server.json
index 05ddd8e..27eb28f 100644
--- a/examples/integration-server/tsconfig.server.json
+++ b/examples/integration-server/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/examples/lazy-auth-server/README.md b/examples/lazy-auth-server/README.md
new file mode 100644
index 0000000..f2e7545
--- /dev/null
+++ b/examples/lazy-auth-server/README.md
@@ -0,0 +1,105 @@
+# Example: Lazy Auth Server
+
+An MCP App example demonstrating **lazy (on-demand) auth**: the server connects and lists tools without any authentication, and only asks for OAuth when a _protected_ tool is actually called โ by answering `401` with a `WWW-Authenticate` header. A public MCP App renders an "Auth me" button; clicking it calls a protected tool via [`callServerTool`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#callservertool). The host sees the 401, runs the OAuth flow, retries, and the result renders inline.
+
+The embedded OAuth authorization server is a deliberately minimal mock (HS256 JWTs, stateless auth codes, auto-approve consent page) so the whole flow runs from a single process with no external dependencies. It is **not** a production authorization server.
+
+## Tools
+
+| Tool | Auth | Description |
+| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------- |
+| `show_auth_button` | public | Renders buttons: "Auth me" (calls `get_secret`), "Revoke token" (calls `revoke_auth_token`) |
+| `get_secret` | **protected** | Returns secret data (requires Bearer token) |
+| `revoke_auth_token` | **protected** | Revokes the caller's **entire auth session** (access + refresh token) โ forces full re-auth |
+| `elicit_url` | public | URL elicitation via `elicitInput` (blocks until the elicitation completes) |
+| `elicit_by_error` | public | URL elicitation via the `-32042` (`UrlElicitationRequired`) error; succeeds on retry after completion |
+
+## Getting Started
+
+```bash
+npm install
+npm start
+# โ MCP endpoint at http://localhost:3097/mcp
+```
+
+To test with a remote MCP host, expose the server through a public tunnel (see [Testing MCP Apps](../../docs/testing-mcp-apps.md)) and set `PUBLIC_URL` to the tunnel URL so OAuth metadata and callback URLs use it:
+
+```bash
+PUBLIC_URL=https:// npm start
+```
+
+This example is HTTP-only (no stdio mode): the lazy-auth flow relies on HTTP status codes and OAuth endpoints.
+
+## Environment Variables
+
+| Var | Required | Description |
+| --------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `JWT_SECRET` | recommended | 32+ byte secret for HS256 signing (`openssl rand -hex 32`). A dev-only default is used if unset. |
+| `PORT` | no | Local port (default 3097) |
+| `PUBLIC_URL` | non-local | Public base URL of the server (e.g. your tunnel URL). Required for non-localhost deployments; client-supplied `Host` headers are only trusted for loopback hosts |
+| `ACCESS_TOKEN_TTL_SECONDS` | no | Default access-token lifetime (default **30**, short on purpose so you can watch the host's refresh flow kick in) |
+| `REFRESH_TOKEN_TTL_SECONDS` | no | Default refresh-token lifetime (default **300**). Between the access and refresh TTLs, calls succeed via silent refresh; past it, a full re-auth is required |
+| `REACTIVE_AUTH_ONLY` | no | Set `1` to remove auth metadata from the root `/.well-known/oauth-*` paths so hosts can't discover auth preemptively โ discovery then only happens via the 401 |
+
+### Per-connection token lifetimes
+
+The short defaults are great for watching the refresh flow, but slow or automated clients may want tokens that survive a whole session. Any client can request a different access-token lifetime by connecting to a TTL-scoped MCP endpoint path (capped at 24 hours):
+
+```
+https:///ttl/3600/mcp โ tokens for this connection live 1 hour
+```
+
+This works through [RFC 8707 resource indicators](https://www.rfc-editor.org/rfc/rfc8707): MCP hosts send the MCP server URL as the `resource` parameter in OAuth authorization and token requests, and this server issues tokens for that grant with the lifetime encoded in the path (refresh tokens are extended to at least match). The TTL is a _path_ segment rather than a query param because hosts canonicalize resource indicators and strip query strings. Each TTL endpoint also enforces its value as a maximum token age, so connecting to a path with a _lower_ TTL than a token's issued lifetime forces the refresh flow. To exercise the full **re-auth** flow, call the `revoke_auth_token` tool.
+
+## Mounting under a base path
+
+`createApp()` can also be mounted inside another Express app, so an existing server can host this example at a sub-path of its own origin:
+
+```ts
+import { createApp } from "@modelcontextprotocol/server-lazy-auth";
+
+hostApp.use("/lazy-auth", createApp());
+// โ MCP endpoint at https:///lazy-auth/mcp
+```
+
+All advertised URLs (OAuth metadata, `WWW-Authenticate` `resource_metadata`, PRM `resource`, elicitation callbacks) include the mount path automatically, derived from Express's `req.baseUrl`. When `PUBLIC_URL` is set, it must include the mount path (e.g. `https://example.com/lazy-auth`).
+
+One thing the mounted app cannot do for itself: [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414#section-3) / [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728) put well-known discovery documents at the _root_ of the origin with the path inserted after the well-known prefix (`/.well-known/oauth-authorization-server/lazy-auth`), and MCP SDK clients only try that insertion form. The host app must rewrite those root paths into the mount before its other routes:
+
+```ts
+hostApp.use((req, _res, next) => {
+ const m = req.url.match(
+ /^\/\.well-known\/(oauth-authorization-server|oauth-protected-resource)\/lazy-auth(\/.*)?$/,
+ );
+ if (m) req.url = `/lazy-auth/.well-known/${m[1]}${m[2] ?? ""}`;
+ next();
+});
+```
+
+Rewriting into the mount (rather than calling the sub-app directly) keeps `req.baseUrl` โ and therefore every advertised URL โ consistent.
+
+## How It Works
+
+1. **Connect without auth** โ `initialize`, `tools/list`, and public tool calls succeed with no `Authorization` header.
+2. **Protected tool โ 401** โ when `get_secret` or `revoke_auth_token` is called without a (valid) Bearer token, the server responds `401` with `WWW-Authenticate: Bearer resource_metadata="โฆ/auth/prm"`.
+3. **Discovery** โ the host follows `resource_metadata` to the protected-resource metadata (RFC 9728), which points at the authorization server metadata (RFC 8414).
+4. **OAuth flow** โ the host runs the authorization-code + PKCE flow against the mock `/authorize` and `/token` endpoints (a small consent page keeps the popup visible).
+5. **Retry** โ the host retries the tool call with the Bearer token and the secret renders inline in the app.
+6. **Refresh + revocation** โ access tokens expire after 30 seconds and refresh tokens after 5 minutes by default, so all three states are easy to observe: direct success (<30s), silent refresh (30sโ5min), and full re-auth (>5min). Connections can request different lifetimes via the `/ttl//mcp` endpoint path (see [Per-connection token lifetimes](#per-connection-token-lifetimes)), and `revoke_auth_token` invalidates the whole session immediately.
+
+The two `elicit_*` tools demonstrate the complementary pattern of [URL elicitation](https://modelcontextprotocol.io/specification/draft/client/elicitation), where the server asks the user to open a URL (e.g. to complete sign-in) either by blocking inside the tool call (`elicit_url`) or by failing with the `-32042` error and succeeding on retry (`elicit_by_error`).
+
+## Architecture
+
+- **Stateless auth codes** โ grant details are encoded _inside_ the authorization code as a 5-minute JWT, so nothing needs to be stored between requests.
+- **Short-lived tokens** โ access tokens default to a **30 second** TTL and refresh tokens to **5 minutes**: first `get_secret` succeeds โ wait >30s โ next call 401s โ host refreshes โ retry succeeds โ wait >5min โ full re-auth. Per-connection overrides via the `/ttl//mcp` endpoint path.
+- **HS256** โ a single shared secret; no key-pair persistence.
+- **Per-request MCP server** โ each `/mcp` request gets a fresh `McpServer` + `StreamableHTTPServerTransport` (stateless, no session IDs).
+- **Session revocation** โ all tokens from one OAuth session share a `sid` claim; `revoke_auth_token` adds the sid to an in-memory revocation list checked by both token verification and the refresh grant.
+
+## Key Files
+
+- [`server.ts`](server.ts) - OAuth endpoints, discovery metadata, and the MCP server with public + protected tools
+- [`main.ts`](main.ts) - HTTP entry point
+- [`mcp-app.html`](mcp-app.html) / [`src/mcp-app.ts`](src/mcp-app.ts) - Public app with the "Auth me" button
+- [`secret-app.html`](secret-app.html) / [`src/secret-app.ts`](src/secret-app.ts) - Protected app rendered for `get_secret`
diff --git a/examples/lazy-auth-server/main.ts b/examples/lazy-auth-server/main.ts
new file mode 100644
index 0000000..a96f34c
--- /dev/null
+++ b/examples/lazy-auth-server/main.ts
@@ -0,0 +1,42 @@
+/**
+ * Entry point for running the Lazy Auth demo MCP server.
+ * Run with: npx mcp-server-lazy-auth
+ * Or: node dist/index.js
+ *
+ * This example is HTTP-only (no stdio mode): the lazy-auth flow it demonstrates
+ * relies on HTTP status codes (401 + WWW-Authenticate) and OAuth endpoints.
+ *
+ * To test with a remote MCP host, expose the server through a public tunnel
+ * and set PUBLIC_URL to the tunnel URL so OAuth metadata and callback URLs
+ * use it (see docs/testing-mcp-apps.md in the repository root).
+ */
+import { createApp, PORT } from "./server.js";
+
+async function main() {
+ const app = createApp();
+
+ const httpServer = app.listen(PORT, (err) => {
+ if (err) {
+ console.error("Failed to start server:", err);
+ process.exit(1);
+ }
+ console.log(
+ `Lazy Auth demo MCP server listening on http://localhost:${PORT}/mcp`,
+ );
+ console.log(
+ ` Tools: show_auth_button, get_secret [PROTECTED], revoke_auth_token [PROTECTED], elicit_url, elicit_by_error`,
+ );
+ });
+
+ const shutdown = () => {
+ console.log("\nShutting down...");
+ httpServer.close(() => process.exit(0));
+ };
+ process.on("SIGINT", shutdown);
+ process.on("SIGTERM", shutdown);
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/examples/lazy-auth-server/mcp-app.html b/examples/lazy-auth-server/mcp-app.html
new file mode 100644
index 0000000..7040f15
--- /dev/null
+++ b/examples/lazy-auth-server/mcp-app.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+ Lazy Auth Demo
+
+
+
+ Public App
+
+ No auth was needed to render this view. Click below to invoke the
+ protected
+ get_secret tool โ the host will run the OAuth flow on 401
+ and retry.
+
+
+ Auth me
+ Revoke token
+ โคข
+
+
+
+
+
+
diff --git a/examples/lazy-auth-server/package.json b/examples/lazy-auth-server/package.json
new file mode 100644
index 0000000..dbd4601
--- /dev/null
+++ b/examples/lazy-auth-server/package.json
@@ -0,0 +1,53 @@
+{
+ "name": "@modelcontextprotocol/server-lazy-auth",
+ "version": "2.0.0",
+ "type": "module",
+ "description": "MCP App example demonstrating lazy (on-demand) OAuth: public tools work unauthenticated, protected tools return 401 + WWW-Authenticate so the host runs the OAuth flow only when needed",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
+ "directory": "examples/lazy-auth-server"
+ },
+ "license": "MIT",
+ "main": "dist/server.js",
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && cross-env INPUT=secret-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"",
+ "watch": "concurrently \"cross-env INPUT=mcp-app.html vite build --watch\" \"cross-env INPUT=secret-app.html vite build --watch\"",
+ "serve": "bun --watch main.ts",
+ "start": "cross-env NODE_ENV=development npm run build && npm run serve",
+ "dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"",
+ "prepublishOnly": "npm run build"
+ },
+ "dependencies": {
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
+ "cors": "^2.8.5",
+ "express": "^5.1.0",
+ "jose": "^6.0.0",
+ "zod": "^4.2.0"
+ },
+ "devDependencies": {
+ "@types/cors": "^2.8.19",
+ "@types/express": "^5.0.0",
+ "@types/node": "22.10.0",
+ "concurrently": "^9.2.1",
+ "cross-env": "^10.1.0",
+ "typescript": "^5.9.3",
+ "vite": "^6.0.0",
+ "vite-plugin-singlefile": "^2.3.0"
+ },
+ "types": "dist/server.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/server.d.ts",
+ "default": "./dist/server.js"
+ }
+ },
+ "bin": {
+ "mcp-server-lazy-auth": "dist/index.js"
+ }
+}
diff --git a/examples/lazy-auth-server/secret-app.html b/examples/lazy-auth-server/secret-app.html
new file mode 100644
index 0000000..b2ab00b
--- /dev/null
+++ b/examples/lazy-auth-server/secret-app.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ Lazy Auth Demo โ Secret
+
+
+
+ Protected App
+ Waiting for secret dataโฆ
+
+
+
+
diff --git a/examples/lazy-auth-server/server.ts b/examples/lazy-auth-server/server.ts
new file mode 100644
index 0000000..5240008
--- /dev/null
+++ b/examples/lazy-auth-server/server.ts
@@ -0,0 +1,1134 @@
+/**
+ * Lazy Auth demo MCP server.
+ *
+ * Demonstrates "lazy" (on-demand) OAuth for MCP servers and Apps:
+ *
+ * - The server connects without authentication: `initialize`, `tools/list`,
+ * and public tool calls all succeed with no Bearer token.
+ * - Protected tools (`get_secret`, `revoke_auth_token`) return 401 with a
+ * `WWW-Authenticate` header pointing at protected-resource metadata, so the
+ * host only runs the OAuth flow when a protected tool is actually called.
+ * - The `show_auth_button` MCP App is public; clicking its button calls the
+ * protected `get_secret` tool via `callServerTool`. The host sees the 401,
+ * runs OAuth, retries, and the result renders inline.
+ *
+ * The embedded OAuth authorization server is intentionally minimal and
+ * stateless (HS256 JWTs, auth codes encoded as short-lived JWTs) so the demo
+ * can run anywhere with a single env var. It is NOT a production AS.
+ */
+import {
+ registerAppResource,
+ registerAppTool,
+ RESOURCE_MIME_TYPE,
+} from "@modelcontextprotocol/ext-apps/server";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
+import {
+ McpServer,
+ UrlElicitationRequiredError,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
+import cors from "cors";
+import express, { type Express, type Request, type Response } from "express";
+import { SignJWT, jwtVerify } from "jose";
+import crypto from "node:crypto";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { z } from "zod";
+
+// Works both from source (server.ts) and compiled (dist/server.js). Derived
+// from import.meta.url rather than import.meta.filename/dirname, which are
+// undefined in some module-VM contexts (e.g. importing this package from
+// jest).
+const SERVER_FILE = fileURLToPath(import.meta.url);
+const DIST_DIR = SERVER_FILE.endsWith(".ts")
+ ? path.join(path.dirname(SERVER_FILE), "dist")
+ : path.dirname(SERVER_FILE);
+
+// โโโ Config โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+export const PORT = parseInt(process.env.PORT ?? "3097", 10);
+const JWT_SECRET = new TextEncoder().encode(
+ process.env.JWT_SECRET ??
+ "dev-insecure-secret-do-not-use-in-production-" + "x".repeat(20),
+);
+const PROTECTED_TOOLS = new Set(["get_secret", "revoke_auth_token"]);
+// Token lifetimes. Defaults are deliberately short so the host's refresh flow
+// (30sโ5min after auth) and full re-auth (>5min) are easy to observe in a demo.
+// Slow or automated clients can request longer-lived tokens per connection by
+// connecting to a TTL-scoped endpoint path (/ttl//mcp) โ see
+// ttlFromResource() and the README.
+const ACCESS_TOKEN_TTL_SECONDS = parseInt(
+ process.env.ACCESS_TOKEN_TTL_SECONDS ?? "30",
+ 10,
+);
+const REFRESH_TOKEN_TTL_SECONDS = parseInt(
+ process.env.REFRESH_TOKEN_TTL_SECONDS ?? "300",
+ 10,
+);
+// Cap on per-connection `?token_ttl_s=` requests.
+const MAX_TOKEN_TTL_SECONDS = 86400;
+// When true: root well-known PRM returns *stub* metadata WITHOUT authorization_servers,
+// and root well-known AS is 404. Hosts probing well-known on connect see "resource has
+// PRM but no auth configured" โ connect without OAuth. Real PRM (with authorization_servers)
+// is only at /auth/prm, reachable via WWW-Authenticate on 401. Default false = standard.
+const REACTIVE_AUTH_ONLY = process.env.REACTIVE_AUTH_ONLY === "1";
+
+/** Hostname is localhost / a loopback address (URL-style, so IPv6 keeps its brackets). */
+function isLoopbackHostname(hostname: string): boolean {
+ return (
+ ["localhost", "127.0.0.1", "[::1]"].includes(hostname) ||
+ hostname.endsWith(".localhost")
+ );
+}
+
+/**
+ * Resolve the public base URL for this deployment.
+ *
+ * Precedence: the PUBLIC_URL env var, then the request's Host header (loopback
+ * hosts only), then http://localhost:PORT. Host / X-Forwarded-* headers naming
+ * non-loopback hosts are deliberately NOT trusted โ a client could spoof them
+ * to make OAuth metadata and callback URLs point at a host it controls.
+ * Non-localhost deployments (e.g. behind a public tunnel) must set PUBLIC_URL
+ * explicitly.
+ */
+function resolvePublicUrl(req?: Request): URL {
+ // Mount path when this app is mounted inside another Express app
+ // (e.g. app.use("/lazy-auth", createApp())). Empty when standalone.
+ // PUBLIC_URL, when set, must already include any mount path.
+ const basePath = req?.baseUrl ?? "";
+ const envUrl = process.env.PUBLIC_URL;
+ if (envUrl) return new URL(envUrl.endsWith("/") ? envUrl : envUrl + "/");
+ const host = req?.headers.host;
+ if (host) {
+ try {
+ const url = new URL(`http://${host}${basePath}/`);
+ if (isLoopbackHostname(url.hostname)) return url;
+ } catch {
+ // Malformed Host header โ fall through to the localhost default.
+ }
+ }
+ return new URL(`http://localhost:${PORT}${basePath}/`);
+}
+
+/** Public base URL as a string with no trailing slash (may include a base path). */
+function publicBaseHref(req?: Request): string {
+ const href = resolvePublicUrl(req).href;
+ return href.endsWith("/") ? href.slice(0, -1) : href;
+}
+
+/** OAuth issuer. In REACTIVE_AUTH_ONLY mode, uses a /auth subpath so root well-known 404s.
+ * Otherwise uses the public base URL (origin + any mount path). */
+const ISSUER_SUFFIX = REACTIVE_AUTH_ONLY ? "/auth" : "";
+function resolveIssuer(req?: Request): string {
+ return publicBaseHref(req) + ISSUER_SUFFIX;
+}
+
+// โโโ Mock OAuth (HS256, stateless codes) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+interface CodePayload {
+ client_id: string;
+ redirect_uri: string;
+ code_challenge?: string;
+ code_challenge_method?: string;
+ scope?: string;
+ /** Requested access-token TTL, from `?token_ttl_s=` on the resource indicator. */
+ token_ttl_s?: number;
+}
+
+/** Validate + cap a raw TTL value (from a path segment or query param). */
+function parseTtlSeconds(raw: string | undefined): number | undefined {
+ const ttl = parseInt(raw ?? "", 10);
+ return Number.isFinite(ttl) && ttl > 0
+ ? Math.min(ttl, MAX_TOKEN_TTL_SECONDS)
+ : undefined;
+}
+
+/**
+ * Extract the requested access-token TTL from a resource indicator (RFC 8707).
+ *
+ * MCP hosts send `resource=` โ the URL the client connected
+ * to โ in authorization and token requests. Clients opt into a non-default
+ * token lifetime by connecting to a TTL-scoped MCP endpoint path:
+ *
+ * /ttl//mcp (e.g. /ttl/3600/mcp for 1-hour tokens)
+ *
+ * The TTL lives in the PATH because hosts canonicalize resource indicators and
+ * strip query strings (verified against real hosts), so `?token_ttl_s=` query
+ * params would never reach this server's OAuth endpoints. The query param is
+ * still honored as a fallback for clients that preserve it.
+ */
+function ttlFromResource(resource: string | undefined): number | undefined {
+ if (!resource) return undefined;
+ try {
+ const url = new URL(resource);
+ const pathMatch = url.pathname.match(/\/ttl\/(\d+)\/mcp$/);
+ return (
+ parseTtlSeconds(pathMatch?.[1]) ??
+ parseTtlSeconds(url.searchParams.get("token_ttl_s") ?? undefined)
+ );
+ } catch {
+ return undefined;
+ }
+}
+
+interface AuthInfo {
+ token: string;
+ sub: string;
+ sid: string;
+}
+
+// โโโ Session revocation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+//
+// Each OAuth session (authorize โ code โ tokens) gets a single `sid`; all access &
+// refresh tokens in that session carry it. Revoking the sid invalidates both โ host
+// must do a full re-OAuth (refresh grant also fails).
+//
+// Storage is an in-memory Map, which is fine for a single-process demo server. If
+// you deploy this to a multi-instance/serverless environment, back it with a
+// shared store instead.
+
+const revokedSids = new Map(); // sid โ unix GC time
+// Revoked-sid entries need only outlive the longest token TTL; add small clock-skew grace.
+const REVOCATION_GC_TTL_SECONDS =
+ Math.max(
+ ACCESS_TOKEN_TTL_SECONDS,
+ REFRESH_TOKEN_TTL_SECONDS,
+ MAX_TOKEN_TTL_SECONDS,
+ ) + 10;
+
+function revokeSession(sid: string): void {
+ revokedSids.set(
+ sid,
+ Math.floor(Date.now() / 1000) + REVOCATION_GC_TTL_SECONDS,
+ );
+ const now = Math.floor(Date.now() / 1000);
+ for (const [k, gc] of revokedSids) if (gc < now) revokedSids.delete(k);
+}
+
+function isSessionRevoked(sid: string | undefined): boolean {
+ if (!sid) return false;
+ return revokedSids.has(sid);
+}
+
+async function signAccessToken(
+ sub: string,
+ scope: string,
+ sid: string,
+ issuer: string,
+ audience: string,
+ ttlSeconds: number = ACCESS_TOKEN_TTL_SECONDS,
+): Promise {
+ return new SignJWT({ sub, scope, sid })
+ .setProtectedHeader({ alg: "HS256" })
+ .setIssuedAt()
+ .setIssuer(issuer)
+ .setAudience(audience)
+ .setExpirationTime(`${ttlSeconds}s`)
+ .sign(JWT_SECRET);
+}
+
+async function signRefreshToken(
+ sub: string,
+ scope: string,
+ sid: string,
+ issuer: string,
+ ttlSeconds: number = REFRESH_TOKEN_TTL_SECONDS,
+ accessTtlSeconds?: number,
+): Promise {
+ // access_ttl_s carries the grant's requested access-token TTL forward so
+ // refreshed access tokens keep the same lifetime as the original.
+ return new SignJWT({
+ sub,
+ scope,
+ sid,
+ typ: "refresh",
+ ...(accessTtlSeconds !== undefined
+ ? { access_ttl_s: accessTtlSeconds }
+ : {}),
+ })
+ .setProtectedHeader({ alg: "HS256" })
+ .setIssuedAt()
+ .setIssuer(issuer)
+ .setExpirationTime(`${ttlSeconds}s`)
+ .sign(JWT_SECRET);
+}
+
+async function verifyRefreshToken(
+ token: string,
+ issuer: string,
+): Promise<
+ | { sub: string; scope: string; sid: string; accessTtlSeconds?: number }
+ | undefined
+> {
+ try {
+ const { payload } = await jwtVerify(token, JWT_SECRET, { issuer });
+ if (payload.typ !== "refresh") return undefined;
+ const sid = payload.sid as string | undefined;
+ if (isSessionRevoked(sid)) return undefined; // session revoked โ refresh fails
+ return {
+ sub: payload.sub ?? "",
+ scope: (payload.scope as string) ?? "",
+ sid: sid ?? "",
+ accessTtlSeconds:
+ typeof payload.access_ttl_s === "number"
+ ? payload.access_ttl_s
+ : undefined,
+ };
+ } catch {
+ return undefined;
+ }
+}
+
+/** Encode grant details inside the code itself (5min expiry) โ no server storage. */
+async function signAuthCode(
+ payload: CodePayload,
+ issuer: string,
+): Promise {
+ return new SignJWT({ ...payload, typ: "code" })
+ .setProtectedHeader({ alg: "HS256" })
+ .setIssuedAt()
+ .setJti(crypto.randomUUID())
+ .setIssuer(issuer)
+ .setExpirationTime("5m")
+ .sign(JWT_SECRET);
+}
+
+// Authorization codes are single-use (RFC 6749 ยง4.1.2): remember redeemed code
+// IDs until the code's own 5-minute expiry makes replay impossible anyway.
+const redeemedCodeJtis = new Map(); // jti โ unix GC time
+
+async function verifyAuthCode(
+ code: string,
+ issuer: string,
+): Promise {
+ try {
+ const { payload } = await jwtVerify(code, JWT_SECRET, { issuer });
+ if (payload.typ !== "code" || !payload.jti) return undefined;
+ const now = Math.floor(Date.now() / 1000);
+ for (const [k, gc] of redeemedCodeJtis)
+ if (gc < now) redeemedCodeJtis.delete(k);
+ if (redeemedCodeJtis.has(payload.jti)) return undefined; // already redeemed
+ redeemedCodeJtis.set(payload.jti, now + 5 * 60 + 10);
+ return payload as unknown as CodePayload;
+ } catch {
+ return undefined;
+ }
+}
+
+async function verifyAccessToken(
+ token: string,
+ issuer: string,
+ maxAgeSeconds?: number,
+): Promise {
+ try {
+ const { payload } = await jwtVerify(token, JWT_SECRET, { issuer });
+ if (payload.typ === "code" || payload.typ === "refresh") return undefined; // only plain access tokens
+ const sid = payload.sid as string | undefined;
+ if (isSessionRevoked(sid)) return undefined; // session revoked
+ // Per-connection TTL override (?token_ttl_s= on the MCP URL): treat tokens
+ // older than maxAgeSeconds as expired even though their exp claim is still
+ // in the future. Lets one deployment serve both long-TTL (default) and
+ // short-TTL (refresh-flow testing) connections.
+ if (maxAgeSeconds !== undefined && typeof payload.iat === "number") {
+ const ageSeconds = Math.floor(Date.now() / 1000) - payload.iat;
+ if (ageSeconds > maxAgeSeconds) return undefined;
+ }
+ return { token, sub: payload.sub ?? "", sid: sid ?? "" };
+ } catch {
+ return undefined;
+ }
+}
+
+function escapeHtml(s: string) {
+ return s.replace(
+ /[&<>"']/g,
+ (c) =>
+ ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
+ c
+ ]!,
+ );
+}
+
+/**
+ * This demo AS has no client registry, so it can't check redirect URIs against
+ * pre-registered values. At minimum, only allow https targets (or http on
+ * loopback for local development hosts) so the consent page never links to,
+ * or redirects the authorization code to, an unexpected scheme.
+ */
+function isAllowedRedirectUri(redirectUri: string): boolean {
+ try {
+ const url = new URL(redirectUri);
+ if (url.protocol === "https:") return true;
+ return url.protocol === "http:" && isLoopbackHostname(url.hostname);
+ } catch {
+ return false;
+ }
+}
+
+async function handleAuthorize(req: Request, res: Response) {
+ const {
+ client_id,
+ redirect_uri,
+ state,
+ code_challenge,
+ code_challenge_method,
+ scope,
+ approved,
+ resource,
+ } = req.query as Record;
+ if (!redirect_uri) {
+ res.status(400).json({
+ error: "invalid_request",
+ error_description: "Missing redirect_uri",
+ });
+ return;
+ }
+ if (!isAllowedRedirectUri(redirect_uri)) {
+ res.status(400).json({
+ error: "invalid_request",
+ error_description: "redirect_uri must use https (or http on localhost)",
+ });
+ return;
+ }
+ // PKCE is mandatory (the MCP auth spec requires it of clients, and this AS
+ // only advertises S256). Rejecting up front keeps stolen-code attacks out of
+ // the demo even though it has no real data to protect.
+ if (!code_challenge || code_challenge_method !== "S256") {
+ res.status(400).json({
+ error: "invalid_request",
+ error_description: "PKCE with S256 code_challenge is required",
+ });
+ return;
+ }
+ const issuer = resolveIssuer(req);
+
+ if (approved !== "1") {
+ // Show consent page. Keeps the OAuth popup visible so users can see the flow.
+ const approveUrl = new URL(publicBaseHref(req) + "/authorize");
+ for (const [k, v] of Object.entries(req.query))
+ if (v) approveUrl.searchParams.set(k, String(v));
+ approveUrl.searchParams.set("approved", "1");
+ const denyUrl = new URL(redirect_uri);
+ denyUrl.searchParams.set("error", "access_denied");
+ if (state) denyUrl.searchParams.set("state", state);
+ res.type("text/html").send(/*html*/ `
+Authorize
+
+
+
+
๐ Mock Authorization
+
An application is requesting access:
+
Client ${escapeHtml(client_id ?? "(none)")}
+Scope ${escapeHtml(scope ?? "(default)")}
+Redirect ${escapeHtml(redirect_uri)}
+
+
+
+`);
+ return;
+ }
+
+ // Per-grant token TTL, requested via `?token_ttl_s=` on the resource
+ // indicator (the MCP URL the host is authorizing for). Encoded into the
+ // auth code so /token can issue tokens with the requested lifetime.
+ const tokenTtl = ttlFromResource(resource);
+ const code = await signAuthCode(
+ {
+ client_id,
+ redirect_uri,
+ code_challenge,
+ code_challenge_method,
+ scope,
+ ...(tokenTtl !== undefined ? { token_ttl_s: tokenTtl } : {}),
+ },
+ issuer,
+ );
+ const redirectUrl = new URL(redirect_uri);
+ redirectUrl.searchParams.set("code", code);
+ if (state) redirectUrl.searchParams.set("state", state);
+ console.log(
+ `[auth] issued code for client_id=${client_id}` +
+ (tokenTtl !== undefined ? ` (token_ttl_s=${tokenTtl})` : ""),
+ );
+ res.redirect(redirectUrl.href);
+}
+
+async function handleToken(req: Request, res: Response) {
+ const { grant_type, code, code_verifier, refresh_token, resource } = req.body;
+ const issuer = resolveIssuer(req);
+ const audience = resolvePublicUrl(req).href;
+
+ if (grant_type === "refresh_token") {
+ const refreshClaims = await verifyRefreshToken(refresh_token, issuer);
+ if (!refreshClaims) {
+ console.log(`[auth] refresh rejected (invalid/revoked)`);
+ res.status(400).json({
+ error: "invalid_grant",
+ error_description: "Refresh token invalid or revoked",
+ });
+ return;
+ }
+ // Refreshed access tokens keep the grant's requested TTL: prefer the
+ // resource indicator on this request, else the TTL carried in the refresh
+ // token, else the default.
+ const accessTtl =
+ ttlFromResource(resource) ??
+ refreshClaims.accessTtlSeconds ??
+ ACCESS_TOKEN_TTL_SECONDS;
+ const access_token = await signAccessToken(
+ refreshClaims.sub,
+ refreshClaims.scope,
+ refreshClaims.sid,
+ issuer,
+ audience,
+ accessTtl,
+ );
+ console.log(
+ `[auth] refreshed access token (sid=${refreshClaims.sid}, ttl=${accessTtl}s)`,
+ );
+ res.json({
+ access_token,
+ token_type: "Bearer",
+ expires_in: accessTtl,
+ scope: refreshClaims.scope,
+ refresh_token,
+ });
+ return;
+ }
+ if (grant_type !== "authorization_code") {
+ res.status(400).json({ error: "unsupported_grant_type" });
+ return;
+ }
+ const stored = await verifyAuthCode(code, issuer);
+ if (!stored) {
+ res.status(400).json({
+ error: "invalid_grant",
+ error_description: "Invalid or expired code",
+ });
+ return;
+ }
+ // PKCE verification (challenges are always present โ /authorize requires them).
+ if (!code_verifier) {
+ res.status(400).json({
+ error: "invalid_grant",
+ error_description: "Missing code_verifier",
+ });
+ return;
+ }
+ const hash = crypto
+ .createHash("sha256")
+ .update(code_verifier)
+ .digest("base64url");
+ if (hash !== stored.code_challenge) {
+ res.status(400).json({
+ error: "invalid_grant",
+ error_description: "PKCE verification failed",
+ });
+ return;
+ }
+ // RFC 6749 ยง4.1.3 redirect_uri binding: if the client includes redirect_uri
+ // in the token request it must match the one from the authorization request.
+ // (OAuth 2.1 clients may omit it and rely on PKCE, which is enforced above.)
+ if (req.body.redirect_uri && req.body.redirect_uri !== stored.redirect_uri) {
+ res.status(400).json({
+ error: "invalid_grant",
+ error_description: "redirect_uri does not match authorization request",
+ });
+ return;
+ }
+ const scope = stored.scope ?? "read:secret";
+ const sid = crypto.randomBytes(16).toString("hex"); // new session per authorization_code grant
+ // Per-grant TTL: prefer the resource indicator on this request (RFC 8707),
+ // else the TTL encoded in the auth code at /authorize, else the default.
+ // Refresh tokens must outlive the access tokens they can mint.
+ const accessTtl =
+ ttlFromResource(resource) ?? stored.token_ttl_s ?? ACCESS_TOKEN_TTL_SECONDS;
+ const refreshTtl = Math.max(REFRESH_TOKEN_TTL_SECONDS, accessTtl);
+ const access_token = await signAccessToken(
+ "mock-user-123",
+ scope,
+ sid,
+ issuer,
+ audience,
+ accessTtl,
+ );
+ const refresh = await signRefreshToken(
+ "mock-user-123",
+ scope,
+ sid,
+ issuer,
+ refreshTtl,
+ accessTtl,
+ );
+ console.log(
+ `[auth] exchanged code โ token (client_id=${stored.client_id}, scope=${scope}, sid=${sid}, access_ttl=${accessTtl}s, refresh_ttl=${refreshTtl}s)`,
+ );
+ res.json({
+ access_token,
+ token_type: "Bearer",
+ expires_in: accessTtl,
+ scope,
+ refresh_token: refresh,
+ });
+}
+
+// โโโ Elicitation state (module-level โ survives across SHTTP requests) โโโโโโ
+//
+// Streamable HTTP creates a fresh McpServer per request, so state must live at
+// module scope. This is fine for a single-process demo server; back it with a
+// shared store if you deploy multiple instances.
+
+/** elicitationIds the user has completed via /elicitation-callback */
+const completedElicitations = new Set();
+/** elicit_by_error: remember the last-issued eid so retries can check it */
+let lastIssuedElicitationId: string | undefined;
+
+// โโโ MCP Server โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+/**
+ * Creates a new MCP server instance with tools and resources registered.
+ *
+ * @param authInfo - Verified Bearer token info, if the request carried one.
+ * @param req - The inbound HTTP request (used to resolve the public URL).
+ */
+export function createServer(authInfo?: AuthInfo, req?: Request): McpServer {
+ const server = new McpServer({ name: "Lazy Auth Demo", version: "1.0.0" });
+
+ const buttonUri = "ui://lazy-auth/mcp-app.html";
+ registerAppTool(
+ server,
+ "show_auth_button",
+ {
+ title: "Show Auth Button",
+ description:
+ "Public tool: shows a button that triggers the protected get_secret tool.",
+ inputSchema: z.object({}),
+ _meta: { ui: { resourceUri: buttonUri } },
+ },
+ async (): Promise => ({
+ content: [{ type: "text", text: "ok" }],
+ }),
+ );
+ registerAppResource(
+ server,
+ buttonUri,
+ buttonUri,
+ { mimeType: RESOURCE_MIME_TYPE },
+ async (): Promise => {
+ const html = await fs.readFile(
+ path.join(DIST_DIR, "mcp-app.html"),
+ "utf-8",
+ );
+ return {
+ contents: [
+ { uri: buttonUri, mimeType: RESOURCE_MIME_TYPE, text: html },
+ ],
+ };
+ },
+ );
+
+ const secretUri = "ui://lazy-auth/secret-app.html";
+ registerAppTool(
+ server,
+ "get_secret",
+ {
+ title: "Get Secret",
+ description:
+ "Protected tool: returns secret data. Requires authentication.",
+ inputSchema: z.object({}),
+ _meta: { ui: { resourceUri: secretUri } },
+ },
+ async (): Promise => {
+ if (!authInfo) {
+ return {
+ isError: true,
+ content: [{ type: "text", text: "Authentication required." }],
+ };
+ }
+ const secret = {
+ subject: authInfo.sub,
+ secret: "the-answer-is-42",
+ issuedAt: new Date().toISOString(),
+ };
+ return {
+ content: [{ type: "text", text: JSON.stringify(secret, null, 2) }],
+ };
+ },
+ );
+ registerAppResource(
+ server,
+ secretUri,
+ secretUri,
+ { mimeType: RESOURCE_MIME_TYPE },
+ async (): Promise => {
+ const html = await fs.readFile(
+ path.join(DIST_DIR, "secret-app.html"),
+ "utf-8",
+ );
+ return {
+ contents: [
+ { uri: secretUri, mimeType: RESOURCE_MIME_TYPE, text: html },
+ ],
+ };
+ },
+ );
+
+ server.registerTool(
+ "revoke_auth_token",
+ {
+ title: "Revoke Auth Token",
+ description:
+ "Protected tool: revokes the caller's entire auth session (access + refresh token).",
+ inputSchema: z.object({}),
+ },
+ async (): Promise => {
+ if (!authInfo) {
+ return {
+ isError: true,
+ content: [{ type: "text", text: "Authentication required." }],
+ };
+ }
+ revokeSession(authInfo.sid);
+ console.log(
+ `[auth] revoked session sid=${authInfo.sid} (total revoked sessions: ${revokedSids.size})`,
+ );
+ return {
+ content: [
+ {
+ type: "text",
+ text: `Session revoked (sid: ${authInfo.sid}). Access + refresh token invalid โ full re-auth required.`,
+ },
+ ],
+ };
+ },
+ );
+
+ // โโ Elicitation test tools โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ // Public tools โ no auth. Used to exercise a host's URL-elicitation flow
+ // end-to-end over Streamable HTTP.
+
+ const base = publicBaseHref(req);
+ const callbackUrl = (eid: string) =>
+ `${base}/elicitation-callback?id=${encodeURIComponent(eid)}`;
+
+ server.registerTool(
+ "elicit_url",
+ {
+ title: "Elicit URL (session-callback)",
+ description:
+ "URL elicitation via elicitInput. Blocks until the client accepts; sends ElicitCompleteNotification before returning.",
+ inputSchema: z.object({}),
+ },
+ async (_args, extra): Promise => {
+ const eid = crypto.randomUUID();
+ // Defer notifier creation โ checks client capabilities which throws if
+ // client doesn't advertise elicitation. Create lazily after elicitInput
+ // succeeds (implies client supports it).
+ console.log(`[elicit_url] eid=${eid} โ blocking on elicitInput`);
+ const result = await server.server.elicitInput(
+ {
+ mode: "url",
+ url: callbackUrl(eid),
+ message: "Please open this URL to continue.",
+ elicitationId: eid,
+ },
+ { relatedRequestId: extra.mcpReq.id, timeout: 300_000 },
+ );
+ // Send completion notification before returning โ lands on the same SSE
+ // stream as this tool response. For Streamable HTTP this is the ONLY
+ // window where the server can push a notification (stream closes right
+ // after). The notification is optional per spec โ skip if the client
+ // doesn't support it.
+ try {
+ const notifier = server.server.createElicitationCompletionNotifier(eid);
+ await notifier();
+ console.log(
+ `[elicit_url] eid=${eid} โ notified, result=${result.action}`,
+ );
+ } catch (e) {
+ console.log(
+ `[elicit_url] eid=${eid} โ notify skipped: ${e instanceof Error ? e.message : e}, result=${result.action}`,
+ );
+ }
+ return {
+ content: [
+ { type: "text", text: `elicit_url: ${JSON.stringify(result)}` },
+ ],
+ };
+ },
+ );
+
+ server.registerTool(
+ "elicit_by_error",
+ {
+ title: "Elicit by -32042 Error",
+ description:
+ "Throws UrlElicitationRequiredError (-32042). Client must open the URL, then retry. On retry, if the last-issued elicitationId was completed, sends ElicitCompleteNotification and succeeds.",
+ inputSchema: z.object({}),
+ },
+ async (): Promise => {
+ // Retry path: check if the user completed the elicitation we last issued.
+ // Module-level state survives across Streamable HTTP requests in a single
+ // process. Checking ANY completed eid (not just last-issued) would be more
+ // forgiving, but last-issued is the stricter real-world pattern hosts must
+ // handle.
+ if (
+ lastIssuedElicitationId &&
+ completedElicitations.has(lastIssuedElicitationId)
+ ) {
+ const eid = lastIssuedElicitationId;
+ completedElicitations.delete(eid);
+ lastIssuedElicitationId = undefined;
+ // Send notification DURING this (retry) tool call โ only window where
+ // the Streamable HTTP stream is open. The notification is optional per
+ // spec โ createElicitationCompletionNotifier throws if the client
+ // doesn't advertise the capability, but the tool still succeeds.
+ try {
+ const notifier =
+ server.server.createElicitationCompletionNotifier(eid);
+ await notifier();
+ console.log(
+ `[elicit_by_error] eid=${eid} โ completed on retry, notified`,
+ );
+ } catch (e) {
+ console.log(
+ `[elicit_by_error] eid=${eid} โ completed on retry, notify skipped: ${e instanceof Error ? e.message : e}`,
+ );
+ }
+ return {
+ content: [
+ {
+ type: "text",
+ text: `Auth completed (${eid}). Tool succeeded on retry.`,
+ },
+ ],
+ };
+ }
+ const eid = crypto.randomUUID();
+ lastIssuedElicitationId = eid;
+ console.log(`[elicit_by_error] eid=${eid} โ throwing -32042`);
+ throw new UrlElicitationRequiredError([
+ {
+ mode: "url",
+ url: callbackUrl(eid),
+ message:
+ "This tool requires authentication. Please open the link to continue.",
+ elicitationId: eid,
+ },
+ ]);
+ },
+ );
+
+ return server;
+}
+
+// โโโ Express app โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+/**
+ * Creates the Express app: OAuth endpoints, discovery metadata, and the /mcp
+ * endpoint with lazy (401-triggered) auth on protected tools.
+ */
+export function createApp(): Express {
+ const app = express();
+ // Wildcard CORS is deliberate: browser-based MCP hosts connect to this demo
+ // from arbitrary origins and must read WWW-Authenticate to drive the lazy
+ // auth flow. There are no cookies or ambient credentials to protect โ all
+ // auth is an explicit Bearer header, and /token is a credential-less PKCE
+ // exchange โ so cross-origin reads expose nothing a direct request wouldn't.
+ app.use(cors({ exposedHeaders: ["WWW-Authenticate"] }));
+ app.use(express.json());
+ app.use(express.urlencoded({ extended: true }));
+
+ // Request tracing: log every inbound request (incl. query params) so we can
+ // see what hosts probe during connect and what they send through OAuth.
+ app.use((req, _res, next) => {
+ const ua = (req.headers["user-agent"] ?? "").slice(0, 80);
+ const auth = req.headers.authorization ? "[Bearer]" : "[no-auth]";
+ const query = Object.keys(req.query).length
+ ? ` query=${JSON.stringify(req.query)}`
+ : "";
+ console.log(`[req] ${req.method} ${req.path} ${auth}${query} ua=${ua}`);
+ next();
+ });
+
+ // OAuth endpoints
+ app.get("/authorize", handleAuthorize);
+ app.post("/token", handleToken);
+
+ // OAuth discovery metadata. In REACTIVE_AUTH_ONLY mode, PRM + AS are kept off the
+ // ROOT /.well-known paths so MCP hosts can't proactively probe them and trigger
+ // preemptive auth during connection โ discovery is purely reactive:
+ // 401 โ WWW-Authenticate โ PRM โ AS โ OAuth flow.
+ const PRM_PATH = "/auth/prm";
+
+ function buildAsMetadata(req: Request) {
+ const base = publicBaseHref(req);
+ return {
+ issuer: resolveIssuer(req), // subpath issuer โ well-known at /.well-known/.../auth
+ authorization_endpoint: `${base}/authorize`,
+ token_endpoint: `${base}/token`,
+ response_types_supported: ["code"],
+ grant_types_supported: ["authorization_code", "refresh_token"],
+ code_challenge_methods_supported: ["S256"],
+ token_endpoint_auth_methods_supported: ["none"],
+ scopes_supported: ["read:secret"],
+ client_id_metadata_document_supported: true,
+ };
+ }
+
+ // AS metadata: serve at the issuer's well-known location.
+ // - REACTIVE_AUTH_ONLY: only at /.well-known/.../auth (subpath); root 404s.
+ // - Default: also at root /.well-known/... (standard; some hosts probe this on connect).
+ if (ISSUER_SUFFIX) {
+ // Per RFC 8414 ยง3: issuer /auth โ /.well-known/oauth-authorization-server/auth
+ app.get(
+ `/.well-known/oauth-authorization-server${ISSUER_SUFFIX}`,
+ (req, res) => res.json(buildAsMetadata(req)),
+ );
+ // RFC 8615 style (well-known between host and path):
+ app.get(
+ `${ISSUER_SUFFIX}/.well-known/oauth-authorization-server`,
+ (req, res) => res.json(buildAsMetadata(req)),
+ );
+ }
+ if (!REACTIVE_AUTH_ONLY) {
+ // Default mode: full AS metadata at root well-known (standard).
+ app.get("/.well-known/oauth-authorization-server", (req, res) =>
+ res.json(buildAsMetadata(req)),
+ );
+ }
+
+ // PRM: full version at custom path (referenced via WWW-Authenticate on 401).
+ function buildPrm(req: Request, includeAuth: boolean, resourcePath = "/mcp") {
+ const base = publicBaseHref(req);
+ return {
+ resource: `${base}${resourcePath}`,
+ ...(includeAuth
+ ? {
+ authorization_servers: [resolveIssuer(req)],
+ scopes_supported: ["read:secret"],
+ bearer_methods_supported: ["header"],
+ }
+ : {}),
+ };
+ }
+ /** Resource path for a TTL-scoped MCP endpoint, validating the TTL segment. */
+ function ttlResourcePath(rawTtl: string): string | undefined {
+ const ttl = parseTtlSeconds(rawTtl);
+ return ttl !== undefined ? `/ttl/${ttl}/mcp` : undefined;
+ }
+ app.get(PRM_PATH, (req, res) => res.json(buildPrm(req, true)));
+ app.get(`${PRM_PATH}/ttl/:ttl`, (req, res) => {
+ const resourcePath = ttlResourcePath(String(req.params.ttl));
+ if (!resourcePath) {
+ res.status(404).json({ error: "invalid_ttl" });
+ return;
+ }
+ res.json(buildPrm(req, true, resourcePath));
+ });
+ // Root + path-dependent well-known PRM: in REACTIVE mode these 404 so hosts don't
+ // interpret their presence as "server has OAuth". In default mode, full PRM.
+ if (!REACTIVE_AUTH_ONLY) {
+ app.get("/.well-known/oauth-protected-resource", (req, res) =>
+ res.json(buildPrm(req, true)),
+ );
+ app.get("/.well-known/oauth-protected-resource/mcp", (req, res) =>
+ res.json(buildPrm(req, true)),
+ );
+ // Well-known PRM for TTL-scoped MCP endpoints (RFC 9728 derives this URL
+ // from the resource path, so each TTL path has its own).
+ app.get(
+ "/.well-known/oauth-protected-resource/ttl/:ttl/mcp",
+ (req, res) => {
+ const resourcePath = ttlResourcePath(String(req.params.ttl));
+ if (!resourcePath) {
+ res.status(404).json({ error: "invalid_ttl" });
+ return;
+ }
+ res.json(buildPrm(req, true, resourcePath));
+ },
+ );
+ }
+
+ // /register: some MCP hosts fall through to OAuth dynamic client registration (RFC 7591)
+ // when all well-known probes 404. We don't support it โ return a proper OAuth error so
+ // the host concludes "auth not available here" and connects without OAuth, instead of
+ // treating a generic 404 as a connection failure.
+ app.post("/register", (_req, res) => {
+ res.status(400).json({
+ error: "invalid_request",
+ error_description:
+ "Dynamic client registration is not supported. This server uses per-tool auth triggered via WWW-Authenticate on 401.",
+ });
+ });
+
+ // MCP endpoint handler, shared by the default and TTL-scoped paths.
+ // pathTtl (from /ttl//mcp) doubles as the acceptance window for
+ // Bearer tokens: tokens older than it are rejected even if their issued
+ // lifetime was longer.
+ async function handleMcp(
+ req: Request,
+ res: Response,
+ pathTtl: number | undefined,
+ ) {
+ const base = publicBaseHref(req);
+ const resourceMetadataUrl =
+ pathTtl !== undefined
+ ? `${base}${PRM_PATH}/ttl/${pathTtl}`
+ : `${base}${PRM_PATH}`;
+
+ const body = req.body;
+ const messages = Array.isArray(body) ? body : body ? [body] : [];
+ const needsAuth = messages.some(
+ (msg: { method?: string; params?: { name?: string } }) =>
+ msg?.method === "tools/call" &&
+ PROTECTED_TOOLS.has(msg.params?.name ?? ""),
+ );
+
+ // Acceptance window: the path TTL, else the `?token_ttl_s=` query param
+ // (fallback for clients that preserve query strings).
+ const tokenMaxAgeSeconds =
+ pathTtl ?? parseTtlSeconds(String(req.query.token_ttl_s ?? ""));
+
+ let authInfo: AuthInfo | undefined;
+ const authHeader = req.headers.authorization;
+ if (authHeader?.startsWith("Bearer ")) {
+ authInfo = await verifyAccessToken(
+ authHeader.slice(7),
+ resolveIssuer(req),
+ tokenMaxAgeSeconds,
+ );
+ if (!authInfo && needsAuth) {
+ console.log(`[mcp] 401: invalid token for protected tool`);
+ res
+ .status(401)
+ .set(
+ "WWW-Authenticate",
+ `Bearer error="invalid_token", error_description="The access token is invalid", resource_metadata="${resourceMetadataUrl}"`,
+ )
+ .json({
+ error: "invalid_token",
+ error_description: "The access token is invalid",
+ });
+ return;
+ }
+ } else if (needsAuth) {
+ console.log(`[mcp] 401: no token for protected tool`);
+ res
+ .status(401)
+ .set(
+ "WWW-Authenticate",
+ `Bearer resource_metadata="${resourceMetadataUrl}"`,
+ )
+ .json({
+ error: "invalid_token",
+ error_description: "Authorization required",
+ });
+ return;
+ }
+
+ const server = createServer(authInfo, req);
+ const transport = new NodeStreamableHTTPServerTransport({
+ sessionIdGenerator: undefined,
+ });
+ res.on("close", () => {
+ transport.close().catch(() => {});
+ server.close().catch(() => {});
+ });
+
+ try {
+ await server.connect(transport);
+ await transport.handleRequest(req, res, req.body);
+ } catch (error) {
+ console.error("[mcp] Error:", error);
+ if (!res.headersSent) {
+ res.status(500).json({
+ jsonrpc: "2.0",
+ error: { code: -32603, message: "Internal server error" },
+ id: null,
+ });
+ }
+ }
+ }
+
+ // Default MCP endpoint (default token TTLs).
+ app.all("/mcp", (req: Request, res: Response) =>
+ handleMcp(req, res, undefined),
+ );
+ // TTL-scoped MCP endpoint: tokens for connections made through this path are
+ // issued with (and limited to) the requested lifetime, e.g. /ttl/3600/mcp.
+ app.all("/ttl/:ttl/mcp", (req: Request, res: Response) => {
+ const pathTtl = parseTtlSeconds(String(req.params.ttl));
+ if (pathTtl === undefined) {
+ res.status(404).json({ error: "invalid_ttl" });
+ return;
+ }
+ return handleMcp(req, res, pathTtl);
+ });
+
+ // Elicitation callback โ marks an eid as completed. Module-level state;
+ // the next elicit_by_error retry reads it. Minimal confirmation page so
+ // the popup shows something.
+ app.get("/elicitation-callback", (req, res) => {
+ const id = String(req.query.id ?? "");
+ if (!id) {
+ res.status(400).type("text/plain").send("missing ?id=");
+ return;
+ }
+ completedElicitations.add(id);
+ console.log(
+ `[callback] eid=${id} completed (total: ${completedElicitations.size})`,
+ );
+ res
+ .type("text/html")
+ .send(
+ `Done ` +
+ `` +
+ `โ Elicitation completed ` +
+ `${escapeHtml(id)}
` +
+ `You can close this tab. The tool will succeed on the next retry.
`,
+ );
+ });
+
+ // Simple landing page
+ app.get("/", (req, res) => {
+ const base = publicBaseHref(req);
+ res
+ .type("text/plain")
+ .send(
+ `Lazy Auth Demo โ MCP server\n\n` +
+ ` MCP endpoint: ${base}/mcp\n` +
+ ` ${base}/ttl//mcp (custom token TTL, e.g. /ttl/3600/mcp)\n` +
+ ` AS metadata: ${base}/.well-known/oauth-authorization-server${ISSUER_SUFFIX}\n` +
+ ` PRM metadata: ${base}${PRM_PATH}\n\n` +
+ `Tools:\n` +
+ ` - show_auth_button (public)\n` +
+ ` - get_secret (protected, requires Bearer token)\n` +
+ ` - revoke_auth_token (protected, revokes caller's auth session)\n` +
+ ` - elicit_url (public, URL elicitation via elicitInput)\n` +
+ ` - elicit_by_error (public, URL elicitation via -32042 error)\n`,
+ );
+ });
+
+ return app;
+}
diff --git a/examples/lazy-auth-server/src/global.css b/examples/lazy-auth-server/src/global.css
new file mode 100644
index 0000000..54a9860
--- /dev/null
+++ b/examples/lazy-auth-server/src/global.css
@@ -0,0 +1,100 @@
+: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
new file mode 100644
index 0000000..800b224
--- /dev/null
+++ b/examples/lazy-auth-server/src/mcp-app.css
@@ -0,0 +1,82 @@
+.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
new file mode 100644
index 0000000..f959d71
--- /dev/null
+++ b/examples/lazy-auth-server/src/mcp-app.ts
@@ -0,0 +1,126 @@
+/**
+ * @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
new file mode 100644
index 0000000..aeb6857
--- /dev/null
+++ b/examples/lazy-auth-server/src/secret-app.ts
@@ -0,0 +1,57 @@
+/**
+ * @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
new file mode 100644
index 0000000..d6a42ce
--- /dev/null
+++ b/examples/lazy-auth-server/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "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
new file mode 100644
index 0000000..27eb28f
--- /dev/null
+++ b/examples/lazy-auth-server/tsconfig.server.json
@@ -0,0 +1,18 @@
+{
+ "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
new file mode 100644
index 0000000..6ff6d99
--- /dev/null
+++ b/examples/lazy-auth-server/vite.config.ts
@@ -0,0 +1,24 @@
+import { defineConfig } from "vite";
+import { viteSingleFile } from "vite-plugin-singlefile";
+
+const INPUT = process.env.INPUT;
+if (!INPUT) {
+ throw new Error("INPUT environment variable is not set");
+}
+
+const isDevelopment = process.env.NODE_ENV === "development";
+
+export default defineConfig({
+ plugins: [viteSingleFile()],
+ build: {
+ sourcemap: isDevelopment ? "inline" : undefined,
+ cssMinify: !isDevelopment,
+ minify: !isDevelopment,
+
+ rollupOptions: {
+ input: INPUT,
+ },
+ outDir: "dist",
+ emptyOutDir: false,
+ },
+});
diff --git a/examples/map-server/README.md b/examples/map-server/README.md
index 4c4de5f..e1e044a 100644
--- a/examples/map-server/README.md
+++ b/examples/map-server/README.md
@@ -122,7 +122,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
-- [`server-utils.ts`](server-utils.ts) - HTTP server utilities
+- [`main.ts`](main.ts) - HTTP and stdio server utilities
## Notes
diff --git a/examples/map-server/main.ts b/examples/map-server/main.ts
index f91f57b..8038394 100644
--- a/examples/map-server/main.ts
+++ b/examples/map-server/main.ts
@@ -4,10 +4,10 @@
* Or: node dist/index.js [--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 { createMcpExpressApp } from "@modelcontextprotocol/express";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
@@ -27,7 +27,7 @@ export async function startStreamableHTTPServer(
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
diff --git a/examples/map-server/mcp-app.html b/examples/map-server/mcp-app.html
index 473eb50..b19eed4 100644
--- a/examples/map-server/mcp-app.html
+++ b/examples/map-server/mcp-app.html
@@ -1,72 +1,90 @@
-
+
-
-
-
+
+
+
CesiumJS Globe
-
-
+
+
-
-
-
-
+
+
+
+
+
+
+
+
Loading globe...
-
+
diff --git a/examples/map-server/package.json b/examples/map-server/package.json
index 54037f8..cc4585f 100644
--- a/examples/map-server/package.json
+++ b/examples/map-server/package.json
@@ -1,11 +1,11 @@
{
"name": "@modelcontextprotocol/server-map",
- "version": "1.1.2",
+ "version": "2.0.0",
"type": "module",
"description": "MCP App Server example with CesiumJS 3D globe and geocoding",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/map-server"
},
"license": "MIT",
@@ -17,20 +17,23 @@
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"",
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
"serve:http": "bun --watch main.ts",
- "serve:stdio": "bun server.ts --stdio",
+ "serve:stdio": "bun main.ts --stdio",
"start": "npm run start:http",
"start:http": "cross-env NODE_ENV=development npm run build && npm run serve:http",
- "start:stdio": "cross-env NODE_ENV=development npm run build && npm run serve:stdio",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
"dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve:http\"",
"prepublishOnly": "npm run build",
"serve": "bun --watch main.ts"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
diff --git a/examples/map-server/server.ts b/examples/map-server/server.ts
index 3dfa749..5767d46 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 } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type {
- CallToolResult,
- ReadResourceResult,
-} from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
@@ -17,7 +17,6 @@ import {
registerAppTool,
registerAppResource,
RESOURCE_MIME_TYPE,
- RESOURCE_URI_META_KEY,
} from "@modelcontextprotocol/ext-apps/server";
import { randomUUID } from "crypto";
@@ -25,7 +24,7 @@ import { randomUUID } from "crypto";
const DIST_DIR = import.meta.filename.endsWith(".ts")
? path.join(import.meta.dirname, "dist")
: import.meta.dirname;
-const RESOURCE_URI = "ui://cesium-map/mcp-app.html";
+const resourceUri = "ui://cesium-map/mcp-app.html";
// Nominatim API response type
interface NominatimResult {
@@ -118,8 +117,8 @@ export function createServer(): McpServer {
// Register the CesiumJS map resource with CSP for external tile sources
registerAppResource(
server,
- RESOURCE_URI,
- RESOURCE_URI,
+ resourceUri,
+ resourceUri,
{ mimeType: RESOURCE_MIME_TYPE },
async (): Promise => {
const html = await fs.readFile(
@@ -130,7 +129,7 @@ export function createServer(): McpServer {
contents: [
// CSP metadata on the content item takes precedence over listing-level _meta
{
- uri: RESOURCE_URI,
+ uri: resourceUri,
mimeType: RESOURCE_MIME_TYPE,
text: html,
_meta: cspMeta,
@@ -148,8 +147,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.",
- inputSchema: {
+ "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({
west: z
.number()
.optional()
@@ -174,8 +173,8 @@ export function createServer(): McpServer {
.string()
.optional()
.describe("Optional label to display on the map"),
- },
- _meta: { [RESOURCE_URI_META_KEY]: RESOURCE_URI },
+ }),
+ _meta: { ui: { resourceUri } },
},
async ({ west, south, east, north, label }): Promise => ({
content: [
@@ -197,13 +196,13 @@ export function createServer(): McpServer {
title: "Geocode",
description:
"Search for places using OpenStreetMap. Returns coordinates and bounding boxes for up to 5 matches.",
- inputSchema: {
+ inputSchema: z.object({
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 1475d81..d3090c7 100644
--- a/examples/map-server/src/mcp-app.ts
+++ b/examples/map-server/src/mcp-app.ts
@@ -6,7 +6,8 @@
* a navigate-to tool for the host to control navigation.
*/
import { App } from "@modelcontextprotocol/ext-apps";
-import type { ContentBlock } from "@modelcontextprotocol/sdk/spec.types.js";
+import type { ContentBlock } from "@modelcontextprotocol/client";
+import { z } from "zod";
// TypeScript declaration for Cesium loaded from CDN
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -397,6 +398,17 @@ 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)
@@ -548,6 +560,76 @@ 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
*/
@@ -623,6 +705,16 @@ 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";
@@ -718,8 +810,8 @@ app.onhostcontextchanged = (params) => {
);
}
- // Update button if available modes changed
- if (params.availableDisplayModes) {
+ // Update button if available modes or safe area changed
+ if (params.availableDisplayModes || params.safeAreaInsets) {
updateFullscreenButton();
}
};
@@ -780,57 +872,110 @@ app.ontoolinput = async (params) => {
}
};
-/*
- 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,
-// };
-// }
+// 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,
+ };
+ }
-// 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})` : ""}`,
-// },
-// ],
-// };
-// },
-// );
+ 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,
+ };
+ },
+);
// 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 535267b..b028ff5 100644
--- a/examples/map-server/tsconfig.json
+++ b/examples/map-server/tsconfig.json
@@ -1,5 +1,6 @@
{
"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 05ddd8e..27eb28f 100644
--- a/examples/map-server/tsconfig.server.json
+++ b/examples/map-server/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/examples/pdf-server/README.md b/examples/pdf-server/README.md
index b73622c..2bee370 100644
--- a/examples/pdf-server/README.md
+++ b/examples/pdf-server/README.md
@@ -149,41 +149,198 @@ 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
+- **Local files**: Must be passed as CLI arguments (or via client roots when enabled)
- **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 |
-| `read_pdf_bytes` | App only | Stream PDF data in chunks |
+| 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 |
## 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)
+โโโ 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
```
## 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()` |
+| 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` |
## Dependencies
-- `pdfjs-dist`: PDF rendering (frontend only)
+- `pdfjs-dist`: PDF rendering and annotation import (frontend only)
+- `pdf-lib`: Client-side PDF modification โ creates proper PDF annotation dictionaries for export
- `@modelcontextprotocol/ext-apps`: MCP Apps SDK
diff --git a/examples/pdf-server/build-mcpb.mjs b/examples/pdf-server/build-mcpb.mjs
new file mode 100644
index 0000000..2917334
--- /dev/null
+++ b/examples/pdf-server/build-mcpb.mjs
@@ -0,0 +1,71 @@
+#!/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 5acd222..0e6e571 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/main.ts b/examples/pdf-server/main.ts
index ddf26c2..bb7b57f 100644
--- a/examples/pdf-server/main.ts
+++ b/examples/pdf-server/main.ts
@@ -6,10 +6,10 @@
import fs from "node:fs";
import path from "node:path";
-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 { createMcpExpressApp } from "@modelcontextprotocol/express";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import cors from "cors";
import type { Request, Response } from "express";
import {
@@ -20,8 +20,10 @@ import {
pathToFileUrl,
fileUrlToPath,
allowedLocalFiles,
+ cliLocalFiles,
DEFAULT_PDF,
allowedLocalDirs,
+ writeFlags,
} from "./server.js";
/**
@@ -37,7 +39,7 @@ export async function startStreamableHTTPServer(
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
@@ -89,14 +91,37 @@ export async function startStdioServer(
await createServer().connect(new StdioServerTransport());
}
-function parseArgs(): { urls: string[]; stdio: boolean } {
+function parseArgs(): {
+ urls: string[];
+ stdio: boolean;
+ useClientRoots: boolean;
+ enableInteract: boolean;
+ debug: 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;
@@ -113,11 +138,17 @@ function parseArgs(): { urls: string[]; stdio: boolean } {
}
}
- return { urls: urls.length > 0 ? urls : [DEFAULT_PDF], stdio };
+ return {
+ urls: urls.length > 0 ? urls : [DEFAULT_PDF],
+ stdio,
+ useClientRoots,
+ enableInteract,
+ debug,
+ };
}
async function main() {
- const { urls, stdio } = parseArgs();
+ const { urls, stdio, useClientRoots, enableInteract, debug } = parseArgs();
// Register local files in whitelist
for (const url of urls) {
@@ -127,6 +158,7 @@ async function main() {
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);
@@ -141,9 +173,21 @@ async function main() {
console.error(`[pdf-server] Ready (${urls.length} URL(s) configured)`);
if (stdio) {
- await startStdioServer(createServer);
+ // stdio โ client is local (e.g. Claude Desktop), roots are safe
+ await startStdioServer(() =>
+ createServer({ enableInteract: true, useClientRoots: true, debug }),
+ );
} else {
- await startStreamableHTTPServer(createServer);
+ // 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 }),
+ );
}
}
diff --git a/examples/pdf-server/manifest.json b/examples/pdf-server/manifest.json
index 47c6bbc..f4b897e 100644
--- a/examples/pdf-server/manifest.json
+++ b/examples/pdf-server/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": "0.3",
"name": "@modelcontextprotocol/server-pdf",
- "display_name": "PDF (By Anthropic)",
+ "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": {
@@ -34,8 +34,8 @@
"description": "Display an interactive PDF viewer with search and navigation"
},
{
- "name": "read_pdf_bytes",
- "description": "Read a range of bytes from a PDF file (max 512KB per request)"
+ "name": "interact",
+ "description": "Interact with a PDF viewer: annotate, navigate, search, extract text/screenshots, fill forms"
}
],
"keywords": ["pdf", "documents", "viewer", "reading"],
diff --git a/examples/pdf-server/mcp-app.html b/examples/pdf-server/mcp-app.html
index c18345d..cf1545a 100644
--- a/examples/pdf-server/mcp-app.html
+++ b/examples/pdf-server/mcp-app.html
@@ -19,6 +19,16 @@
An error occurred
+
+
+
@@ -60,6 +70,31 @@
+
+
+
+
+
+
+
+
+
+
+
- โถ
+
+
@@ -107,12 +143,31 @@
-
-
-
diff --git a/examples/pdf-server/package.json b/examples/pdf-server/package.json
index beac872..c8d9cf8 100644
--- a/examples/pdf-server/package.json
+++ b/examples/pdf-server/package.json
@@ -1,11 +1,11 @@
{
"name": "@modelcontextprotocol/server-pdf",
- "version": "1.1.2",
+ "version": "2.0.0",
"type": "module",
"description": "MCP server for loading and extracting text from PDF files with chunked pagination and interactive viewer",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/pdf-server"
},
"license": "MIT",
@@ -14,20 +14,27 @@
"dist"
],
"scripts": {
- "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node --external pdfjs-dist && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --external pdfjs-dist --banner \"#!/usr/bin/env node\"",
+ "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build pdfjs-polyfill.ts --outdir dist --target node && bun build server.ts --outdir dist --target node --external pdfjs-dist --external \"./pdfjs-polyfill.js\" && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --external pdfjs-dist --banner \"#!/usr/bin/env node\"",
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
- "serve": "bun --watch main.ts",
+ "serve": "bun --watch main.ts --enable-interact",
+ "serve:stdio": "bun main.ts --stdio",
"start": "cross-env NODE_ENV=development npm run build && npm run serve",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
"dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"",
- "prepublishOnly": "npm run build"
+ "prepublishOnly": "npm run build",
+ "build:mcpb": "npm run build && node build-mcpb.mjs"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@cantoo/pdf-lib": "^2.6.5",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"pdfjs-dist": "^5.0.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
diff --git a/examples/pdf-server/pdfjs-polyfill.ts b/examples/pdf-server/pdfjs-polyfill.ts
new file mode 100644
index 0000000..80e1705
--- /dev/null
+++ b/examples/pdf-server/pdfjs-polyfill.ts
@@ -0,0 +1,26 @@
+/**
+ * Stub polyfills for pdfjs-dist's legacy build under Node.js.
+ *
+ * pdfjs-dist/legacy/build/pdf.mjs eagerly does `new DOMMatrix()` at module
+ * scope. Its own polyfill loads `@napi-rs/canvas` (an optionalDependency),
+ * but `npx` / fresh installs frequently miss the platform-native binary
+ * (npm optional-deps bug, see https://github.com/npm/cli/issues/4828),
+ * so the import crashes before our server code runs.
+ *
+ * The server only uses pdfjs for text/metadata/form-field extraction โ
+ * never canvas rendering โ so no-op stubs are sufficient and avoid
+ * shipping ~130MB of native binaries we don't need.
+ *
+ * MUST be a separate ESM module imported before pdfjs-dist (and kept
+ * `--external` in the bun bundle) so it executes before pdfjs's
+ * module-level initializer. Inlined body code would run too late
+ * because static imports are hoisted.
+ */
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+const g = globalThis as any;
+
+// Only stub if missing โ let a real @napi-rs/canvas (or jsdom) win.
+g.DOMMatrix ??= class DOMMatrix {};
+g.ImageData ??= class ImageData {};
+g.Path2D ??= class Path2D {};
diff --git a/examples/pdf-server/plugin/.claude-plugin/plugin.json b/examples/pdf-server/plugin/.claude-plugin/plugin.json
deleted file mode 100644
index f7442e7..0000000
--- a/examples/pdf-server/plugin/.claude-plugin/plugin.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "name": "pdf-research",
- "version": "0.1.0",
- "description": "Read, navigate, and analyze PDF documents โ academic papers, reports, and local files. Uses a local PDF MCP server for interactive viewing and text extraction.",
- "author": {
- "name": "Anthropic"
- }
-}
diff --git a/examples/pdf-server/plugin/.mcp.json b/examples/pdf-server/plugin/.mcp.json
deleted file mode 100644
index f81cdb2..0000000
--- a/examples/pdf-server/plugin/.mcp.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "mcpServers": {
- "pdf": {
- "command": "npx",
- "args": ["@modelcontextprotocol/server-pdf", "--stdio"]
- }
- }
-}
diff --git a/examples/pdf-server/plugin/CONNECTORS.md b/examples/pdf-server/plugin/CONNECTORS.md
deleted file mode 100644
index 39def09..0000000
--- a/examples/pdf-server/plugin/CONNECTORS.md
+++ /dev/null
@@ -1,16 +0,0 @@
-# Connectors
-
-## Local MCP Server
-
-This plugin uses a **local MCP server** instead of a remote connector.
-The PDF server runs on your machine via `npx`.
-
-| Category | Server | How it runs |
-| ---------- | ---------------------------------- | -------------------------------------- |
-| PDF viewer | `@modelcontextprotocol/server-pdf` | Local stdio via `npx` (auto-installed) |
-
-### Requirements
-
-- Node.js >= 18
-- Internet access for remote PDFs (arXiv, bioRxiv, etc.)
-- No API keys or authentication needed
diff --git a/examples/pdf-server/plugin/README.md b/examples/pdf-server/plugin/README.md
deleted file mode 100644
index 32a80ab..0000000
--- a/examples/pdf-server/plugin/README.md
+++ /dev/null
@@ -1,40 +0,0 @@
-# PDF Research Plugin
-
-Read and analyze PDF documents using a local MCP server. Designed for
-academic paper review, document analysis, and research workflows.
-
-## What It Does
-
-- **Open PDFs** from local files, arXiv, bioRxiv, and other academic sources
-- **Interactive viewer** with search, navigation, and zoom
-- **AI-powered analysis** -- summarize, extract key points, compare papers
-
-## Commands
-
-| Command | What it does |
-| ------------------------- | ---------------------- |
-| `/pdf-research:read` | Open a PDF for reading |
-| `/pdf-research:summarize` | Summarize a document |
-
-## How It Works
-
-This plugin uses a **local MCP server** (`@modelcontextprotocol/server-pdf`)
-that runs on your machine via `npx`. No API keys or remote services needed --
-the PDF server starts automatically when the plugin loads.
-
-This is different from other knowledge-work plugins which use remote HTTP
-connectors. The local server pattern enables offline PDF access and avoids
-the need for a remote deployment.
-
-## Requirements
-
-- Node.js >= 18
-- Internet for remote PDFs (arXiv, etc.)
-
-## Supported PDF Sources
-
-- Local files (file paths in your working directory)
-- [arXiv](https://arxiv.org) papers
-- [bioRxiv](https://biorxiv.org) / [medRxiv](https://medrxiv.org) preprints
-- [chemRxiv](https://chemrxiv.org), [Zenodo](https://zenodo.org), [OSF](https://osf.io)
-- [HAL Science](https://hal.science), [SSRN](https://ssrn.com)
diff --git a/examples/pdf-server/plugin/commands/read.md b/examples/pdf-server/plugin/commands/read.md
deleted file mode 100644
index 39817bc..0000000
--- a/examples/pdf-server/plugin/commands/read.md
+++ /dev/null
@@ -1,21 +0,0 @@
----
-description: Open a PDF for reading and analysis
----
-
-# Read PDF
-
-Open a PDF document for interactive reading. Accepts local file paths,
-arXiv URLs, or other academic paper URLs.
-
-## Instructions
-
-1. If the user provides a URL or file path, call `display_pdf` with that URL
-2. If no URL given, call `list_pdfs` first to show available documents
-3. After displaying, offer to summarize, extract key points, or answer questions
-
-## Supported Sources
-
-- Local files (drag-and-drop or file path)
-- arXiv papers (arxiv.org/abs/... or arxiv.org/pdf/...)
-- bioRxiv, medRxiv, chemRxiv preprints
-- Zenodo, OSF, HAL, SSRN
diff --git a/examples/pdf-server/plugin/commands/summarize.md b/examples/pdf-server/plugin/commands/summarize.md
deleted file mode 100644
index 62ee4ba..0000000
--- a/examples/pdf-server/plugin/commands/summarize.md
+++ /dev/null
@@ -1,23 +0,0 @@
----
-description: Summarize the current PDF or a new document
----
-
-# Summarize PDF
-
-Generate a structured summary of a PDF document.
-
-## Instructions
-
-1. If a PDF is already displayed, work with it
-2. If user provides a new URL/path, call `display_pdf` first
-3. Read through the document and produce:
- - **Title and authors** (if academic paper)
- - **Key findings** (3-5 bullet points)
- - **Methodology** (brief summary)
- - **Conclusions**
- - **Relevance** โ what makes this paper notable
-
-## Notes
-
-- For long documents, focus on abstract, introduction, and conclusion sections
-- Ask the user which sections they care most about if the doc is very long
diff --git a/examples/pdf-server/plugin/skills/pdf-reading/SKILL.md b/examples/pdf-server/plugin/skills/pdf-reading/SKILL.md
deleted file mode 100644
index 2e79917..0000000
--- a/examples/pdf-server/plugin/skills/pdf-reading/SKILL.md
+++ /dev/null
@@ -1,35 +0,0 @@
----
-name: pdf-reading
-description: Read and navigate PDF documents using the local PDF server. Reference this when the user wants to open, read, search, or interact with any PDF file or academic paper.
----
-
-# PDF Reading
-
-You have access to a local PDF server that provides interactive document viewing.
-
-## Available Tools
-
-- **list_pdfs** -- Show available PDFs. Call with no arguments.
-- **display_pdf** -- Open a PDF in the interactive viewer.
- - `url` (string): URL or local file path
- - `page` (number, optional): Starting page number
-
-## How to Use
-
-**When the user mentions a PDF, paper, or document:**
-1. If they give a URL or path, call `display_pdf` directly
-2. If they say "open the paper" without specifying, call `list_pdfs` to show options
-3. After displaying, offer to summarize, extract data, answer questions
-
-**arXiv shortcuts:**
-- `arxiv.org/abs/2301.12345` is auto-converted to the PDF URL
-- Users can just say "open arxiv 2301.12345"
-
-**Supported remote sources:**
-arXiv, bioRxiv, medRxiv, chemRxiv, Zenodo, OSF, HAL Science, SSRN
-
-## Best Practices
-
-- Always display the PDF before trying to analyze it
-- For multi-page documents, ask which section the user cares about
-- When comparing papers, display them one at a time and note key differences
diff --git a/examples/pdf-server/screenshot.png b/examples/pdf-server/screenshot.png
index bdb8f17..fd87f8f 100644
Binary files a/examples/pdf-server/screenshot.png and b/examples/pdf-server/screenshot.png differ
diff --git a/examples/pdf-server/server.test.ts b/examples/pdf-server/server.test.ts
index dd5ac44..0b2a5eb 100644
--- a/examples/pdf-server/server.test.ts
+++ b/examples/pdf-server/server.test.ts
@@ -1,12 +1,28 @@
import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test";
+import fs from "node:fs";
+import os from "node:os";
import path from "node:path";
+import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
+import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
+import { PDFDocument } from "@cantoo/pdf-lib";
+import { makeRandomJpeg } from "../../tests/helpers/range-counting-server";
import {
createPdfCache,
+ createServer,
+ extractFormSchema,
+ PdfCacheRangeTransport,
+ MAX_CHUNK_BYTES,
validateUrl,
isAncestorDir,
allowedLocalFiles,
allowedLocalDirs,
pathToFileUrl,
+ startFileWatch,
+ stopFileWatch,
+ cliLocalFiles,
+ isWritablePath,
+ writeFlags,
+ viewSourcePaths,
CACHE_INACTIVITY_TIMEOUT_MS,
CACHE_MAX_LIFETIME_MS,
CACHE_MAX_PDF_SIZE_BYTES,
@@ -60,6 +76,72 @@ describe("PDF Cache with Timeouts", () => {
});
});
+ describe("byte-cap LRU eviction", () => {
+ const tenBytes = new Uint8Array(10);
+
+ async function fill(cache: PdfCache, url: string): Promise
{
+ const m = spyOn(globalThis, "fetch").mockResolvedValueOnce(
+ new Response(tenBytes, { status: 200 }),
+ );
+ try {
+ await cache.readPdfRange(url, 0, 1024);
+ } finally {
+ m.mockRestore();
+ }
+ }
+
+ it("evicts least-recently-used entry when total exceeds cap", async () => {
+ const cache = createPdfCache(25);
+ try {
+ await fill(cache, "https://arxiv.org/pdf/a");
+ await fill(cache, "https://arxiv.org/pdf/b");
+ expect(cache.getCacheSize()).toBe(2);
+
+ // Touch A so B becomes least-recently-used
+ await cache.readPdfRange("https://arxiv.org/pdf/a", 0, 1);
+
+ // Inserting C (10B) pushes total to 30 > 25 โ evict LRU (B)
+ await fill(cache, "https://arxiv.org/pdf/c");
+ expect(cache.getCacheSize()).toBe(2);
+
+ // A and C still served from cache; B re-fetches
+ const m = spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(tenBytes, { status: 200 }),
+ );
+ try {
+ await cache.readPdfRange("https://arxiv.org/pdf/a", 0, 1);
+ await cache.readPdfRange("https://arxiv.org/pdf/c", 0, 1);
+ expect(m).toHaveBeenCalledTimes(0);
+ await cache.readPdfRange("https://arxiv.org/pdf/b", 0, 1);
+ expect(m).toHaveBeenCalledTimes(1);
+ } finally {
+ m.mockRestore();
+ }
+ } finally {
+ cache.clearCache();
+ }
+ });
+
+ it("evicts multiple entries if a single insert exceeds the cap", async () => {
+ const cache = createPdfCache(25);
+ try {
+ await fill(cache, "https://arxiv.org/pdf/a");
+ await fill(cache, "https://arxiv.org/pdf/b");
+ const big = spyOn(globalThis, "fetch").mockResolvedValueOnce(
+ new Response(new Uint8Array(20), { status: 200 }),
+ );
+ try {
+ await cache.readPdfRange("https://arxiv.org/pdf/big", 0, 1024);
+ } finally {
+ big.mockRestore();
+ }
+ expect(cache.getCacheSize()).toBe(1);
+ } finally {
+ cache.clearCache();
+ }
+ });
+ });
+
describe("readPdfRange caching behavior", () => {
const testUrl = "https://arxiv.org/pdf/test-pdf";
const testData = new Uint8Array([0x25, 0x50, 0x44, 0x46]); // %PDF header
@@ -212,6 +294,266 @@ describe("PDF Cache with Timeouts", () => {
// through manual testing or E2E tests.
});
+describe("PdfCacheRangeTransport", () => {
+ it("accumulates ranges larger than MAX_CHUNK_BYTES into one onDataRange call", async () => {
+ const big = MAX_CHUNK_BYTES * 2 + 100;
+ const reads: Array<[number, number]> = [];
+ const t = new PdfCacheRangeTransport("u", big, async (_u, off, n) => {
+ reads.push([off, n]);
+ return {
+ data: new Uint8Array(Math.min(n, MAX_CHUNK_BYTES)),
+ totalBytes: big,
+ };
+ });
+ const delivered: Array<[number, number]> = [];
+ t.addRangeListener((begin: number, chunk: Uint8Array) =>
+ delivered.push([begin, chunk.length]),
+ );
+ t.requestDataRange(0, big);
+ await new Promise((r) => setTimeout(r, 10));
+ // pdf.js's reader is keyed by the original begin and removed after one
+ // delivery, so deliver() must call onDataRange exactly once with the
+ // accumulated buffer โ multiple calls would throw inside pdfjs.
+ expect(delivered).toEqual([[0, big]]);
+ expect(reads).toEqual([
+ [0, MAX_CHUNK_BYTES],
+ [MAX_CHUNK_BYTES, MAX_CHUNK_BYTES],
+ [MAX_CHUNK_BYTES * 2, 100],
+ ]);
+ });
+
+ it("rejects .failed when a range fetch errors instead of hanging", async () => {
+ const t = new PdfCacheRangeTransport("u", 1000, async () => {
+ throw new Error("network down");
+ });
+ t.requestDataRange(0, 100);
+ await expect(
+ Promise.race([
+ t.failed,
+ new Promise((r) => setTimeout(() => r("timeout"), 200)),
+ ]),
+ ).rejects.toThrow("network down");
+ });
+
+ it("rejects .failed on zero-length response (would otherwise spin)", async () => {
+ const t = new PdfCacheRangeTransport("u", 1000, async () => ({
+ data: new Uint8Array(0),
+ totalBytes: 1000,
+ }));
+ t.requestDataRange(0, 100);
+ await expect(t.failed).rejects.toThrow(/empty range/);
+ });
+
+ it("getDocument resolves on a >1MB PDF when readPdfRange clamps to MAX_CHUNK_BYTES", async () => {
+ // pdfjs coalesces adjacent missing chunks into one requestDataRange that
+ // can exceed MAX_CHUNK_BYTES. deliver() must accumulate clamped reads and
+ // hand pdfjs a single onDataRange(begin, fullBuffer). This test fails if
+ // deliver() either truncates or calls onDataRange more than once per
+ // requestDataRange (pdf.mjs _onReceiveData matches by exact begin).
+ const d = await PDFDocument.create();
+ const img = await d.embedJpg(makeRandomJpeg(1_100_000));
+ const page = d.addPage([612, 792]);
+ page.drawImage(img, { x: 36, y: 36, width: 540, height: 720 });
+ const bytes = await d.save();
+ expect(bytes.length).toBeGreaterThan(2 * MAX_CHUNK_BYTES);
+
+ const readClamped: PdfCache["readPdfRange"] = async (_u, off, n) => {
+ const len = Math.min(n, MAX_CHUNK_BYTES, bytes.length - off);
+ return { data: bytes.slice(off, off + len), totalBytes: bytes.length };
+ };
+ // Record the spans pdfjs actually requests so the test fails fast if it
+ // never asks for >MAX_CHUNK_BYTES (i.e. can't go vacuously green).
+ const spans: number[] = [];
+ class RecordingTransport extends PdfCacheRangeTransport {
+ override requestDataRange(begin: number, end: number): void {
+ spans.push(end - begin);
+ super.requestDataRange(begin, end);
+ }
+ }
+ const transport = new RecordingTransport(
+ "mem://big",
+ bytes.length,
+ readClamped,
+ );
+
+ const orHang = (p: Promise, what: string): Promise =>
+ Promise.race([
+ p,
+ transport.failed,
+ new Promise((_, rej) =>
+ setTimeout(() => rej(new Error(`${what} hung`)), 5000),
+ ),
+ ]);
+
+ const doc = await orHang(
+ getDocument({
+ range: transport,
+ length: bytes.length,
+ disableAutoFetch: true,
+ disableStream: true,
+ rangeChunkSize: 64 * 1024,
+ }).promise,
+ "getDocument",
+ );
+ const p1 = await orHang(doc.getPage(1), "getPage");
+ // getPage() alone doesn't decode the image XObject; getOperatorList() does,
+ // which is what triggers the >512KB coalesced range request.
+ await orHang(p1.getOperatorList(), "getOperatorList");
+ expect(Math.max(...spans)).toBeGreaterThan(MAX_CHUNK_BYTES);
+ doc.destroy();
+ });
+});
+
+describe("display_pdf transport-error handling", () => {
+ it("returns (does not hang) when range fetches fail mid-load", async () => {
+ // First fetch = the 1-byte size probe โ 206 with Content-Range so
+ // display_pdf gets totalBytes. Every subsequent fetch (made by
+ // PdfCacheRangeTransport via readPdfRange) rejects, which must surface
+ // through transport.failed โ orFail() โ outer catch, not hang.
+ let calls = 0;
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation(
+ async () => {
+ if (calls++ === 0) {
+ return new Response(new Uint8Array(1), {
+ status: 206,
+ headers: { "Content-Range": "bytes 0-0/50000" },
+ });
+ }
+ throw new Error("network down");
+ },
+ );
+
+ const server = createServer();
+ const client = new Client({ name: "t", version: "1" });
+ const [ct, st] = InMemoryTransport.createLinkedPair();
+ await Promise.all([server.connect(st), client.connect(ct)]);
+
+ try {
+ const result = await Promise.race([
+ client.callTool({
+ name: "display_pdf",
+ arguments: { url: "https://arxiv.org/pdf/err-test" },
+ }),
+ new Promise((_, rej) =>
+ setTimeout(
+ () => rej(new Error("display_pdf hung on transport error")),
+ 3000,
+ ),
+ ),
+ ]);
+ expect(result.isError).toBeFalsy();
+ const sc = result.structuredContent as { formFields?: unknown };
+ expect(sc.formFields).toBeUndefined();
+ expect(calls).toBeGreaterThan(1);
+ } finally {
+ mockFetch.mockRestore();
+ await client.close();
+ await server.close();
+ }
+ });
+});
+
+describe("extractFormSchema field-tree handling", () => {
+ async function schemaFor(bytes: Uint8Array) {
+ const doc = await getDocument({ data: bytes }).promise;
+ try {
+ const fo = (await doc.getFieldObjects()) as Parameters<
+ typeof extractFormSchema
+ >[1];
+ return await extractFormSchema(doc, fo);
+ } finally {
+ doc.destroy();
+ }
+ }
+
+ it("handles pdf-lib separated field/widget structure", async () => {
+ const d = await PDFDocument.create();
+ const form = d.getForm();
+ d.addPage([612, 792]);
+ form
+ .createTextField("alpha")
+ .addToPage(d.getPage(0), { x: 50, y: 700, width: 200, height: 20 });
+ form
+ .createCheckBox("agree")
+ .addToPage(d.getPage(0), { x: 50, y: 660, width: 20, height: 20 });
+ form
+ .createDropdown("choice")
+ .addToPage(d.getPage(0), { x: 50, y: 620, width: 100, height: 20 });
+
+ const schema = await schemaFor(await d.save());
+ expect(schema).not.toBeNull();
+ expect(schema!.properties.alpha).toEqual({
+ type: "string",
+ title: "alpha",
+ });
+ expect(schema!.properties.agree).toEqual({
+ type: "boolean",
+ title: "agree",
+ });
+ expect(schema!.properties.choice.type).toBe("string");
+ });
+
+ it("handles fields with multiple widgets across pages", async () => {
+ const d = await PDFDocument.create();
+ const form = d.getForm();
+ d.addPage([612, 792]);
+ d.addPage([612, 792]);
+ const tf = form.createTextField("shared");
+ tf.addToPage(d.getPage(0), { x: 50, y: 700, width: 200, height: 20 });
+ tf.addToPage(d.getPage(1), { x: 50, y: 700, width: 200, height: 20 });
+
+ const schema = await schemaFor(await d.save());
+ expect(schema?.properties.shared).toEqual({
+ type: "string",
+ title: "shared",
+ });
+ });
+
+ it("skips container nodes and finds leaf fields (W-9 style)", async () => {
+ const bytes = fs.readFileSync(
+ path.join(__dirname, "../../tests/helpers/assets/fw9.pdf"),
+ );
+ const doc = await getDocument({ data: new Uint8Array(bytes) }).promise;
+ try {
+ const fo = (await doc.getFieldObjects()) as Parameters<
+ typeof extractFormSchema
+ >[1];
+ // Container nodes (no leaf type) should not crash extraction
+ expect(fo!["topmostSubform[0]"]).toBeDefined();
+ // Schema is null for W-9 (mechanical names), but extraction must not throw
+ const schema = await extractFormSchema(doc, fo);
+ expect(schema).toBeNull();
+ } finally {
+ doc.destroy();
+ }
+ });
+
+ it("returns null when no AcroForm present", async () => {
+ const d = await PDFDocument.create();
+ d.addPage([612, 792]);
+ const schema = await schemaFor(await d.save());
+ expect(schema).toBeNull();
+ });
+});
+
+describe("validateUrl loopback HTTP allow (PDF_SERVER_ALLOW_LOOPBACK_HTTP)", () => {
+ it("rejects http://127.0.0.1 by default", () => {
+ expect(validateUrl("http://127.0.0.1:9999/x.pdf").valid).toBe(false);
+ });
+
+ it("accepts http://127.0.0.1 only when the env gate is set, and never non-loopback http", () => {
+ const prev = process.env.PDF_SERVER_ALLOW_LOOPBACK_HTTP;
+ process.env.PDF_SERVER_ALLOW_LOOPBACK_HTTP = "1";
+ try {
+ expect(validateUrl("http://127.0.0.1:9999/x.pdf").valid).toBe(true);
+ expect(validateUrl("http://169.254.169.254/").valid).toBe(false);
+ } finally {
+ if (prev === undefined) delete process.env.PDF_SERVER_ALLOW_LOOPBACK_HTTP;
+ else process.env.PDF_SERVER_ALLOW_LOOPBACK_HTTP = prev;
+ }
+ });
+});
+
describe("validateUrl with MCP roots (allowedLocalDirs)", () => {
const savedFiles = new Set(allowedLocalFiles);
const savedDirs = new Set(allowedLocalDirs);
@@ -421,3 +763,1055 @@ describe("isAncestorDir", () => {
);
});
});
+
+describe("createServer useClientRoots option", () => {
+ it("should not set up roots handlers by default", () => {
+ const server = createServer();
+ // When useClientRoots is false (default), oninitialized should NOT
+ // be overridden by our roots logic.
+ expect(server.server.oninitialized).toBeUndefined();
+ server.close();
+ });
+
+ it("should not set up roots handlers when useClientRoots is false", () => {
+ const server = createServer({ useClientRoots: false });
+ expect(server.server.oninitialized).toBeUndefined();
+ server.close();
+ });
+
+ it("should set up roots handlers when useClientRoots is true", () => {
+ const server = createServer({ useClientRoots: true });
+ // When useClientRoots is true, oninitialized should be set to
+ // the roots refresh handler.
+ expect(server.server.oninitialized).toBeFunction();
+ server.close();
+ });
+});
+
+describe("isWritablePath", () => {
+ let savedFiles: Set;
+ let savedDirs: Set;
+ let savedCli: Set;
+ let savedAllowUploadsRoot: boolean;
+
+ beforeEach(() => {
+ savedFiles = new Set(allowedLocalFiles);
+ savedDirs = new Set(allowedLocalDirs);
+ savedCli = new Set(cliLocalFiles);
+ allowedLocalFiles.clear();
+ allowedLocalDirs.clear();
+ cliLocalFiles.clear();
+ savedAllowUploadsRoot = writeFlags.allowUploadsRoot;
+ writeFlags.allowUploadsRoot = false;
+ });
+
+ afterEach(() => {
+ allowedLocalFiles.clear();
+ allowedLocalDirs.clear();
+ cliLocalFiles.clear();
+ for (const x of savedFiles) allowedLocalFiles.add(x);
+ for (const x of savedDirs) allowedLocalDirs.add(x);
+ for (const x of savedCli) cliLocalFiles.add(x);
+ writeFlags.allowUploadsRoot = savedAllowUploadsRoot;
+ });
+
+ it("nothing is writable when no roots and no CLI files", () => {
+ expect(isWritablePath("/any/path/file.pdf")).toBe(false);
+ });
+
+ it("CLI file is writable", () => {
+ allowedLocalFiles.add("/tmp/explicit.pdf");
+ cliLocalFiles.add("/tmp/explicit.pdf");
+ expect(isWritablePath("/tmp/explicit.pdf")).toBe(true);
+ });
+
+ it("MCP file root is NOT writable", () => {
+ allowedLocalFiles.add("/tmp/uploaded.pdf"); // from refreshRoots, no CLI
+ expect(isWritablePath("/tmp/uploaded.pdf")).toBe(false);
+ });
+
+ it("file under a directory root at any depth is writable", () => {
+ allowedLocalDirs.add("/home/user/docs");
+ expect(isWritablePath("/home/user/docs/file.pdf")).toBe(true);
+ expect(isWritablePath("/home/user/docs/sub/deep/file.pdf")).toBe(true);
+ });
+
+ it("the directory root itself is NOT writable", () => {
+ allowedLocalDirs.add("/home/user/docs");
+ expect(isWritablePath("/home/user/docs")).toBe(false);
+ });
+
+ it("MCP file root stays read-only even when under a directory root", () => {
+ // Client sent BOTH the directory and a file inside it as roots.
+ // The explicit file-root is the stronger signal: treat as upload.
+ allowedLocalDirs.add("/home/user/docs");
+ allowedLocalFiles.add("/home/user/docs/uploaded.pdf");
+ expect(isWritablePath("/home/user/docs/uploaded.pdf")).toBe(false);
+ // Siblings not sent as file roots remain writable
+ expect(isWritablePath("/home/user/docs/other.pdf")).toBe(true);
+ });
+
+ it("CLI file wins even if also in allowedLocalFiles", () => {
+ // CLI file added to both sets (main.ts does this)
+ allowedLocalFiles.add("/tmp/cli.pdf");
+ cliLocalFiles.add("/tmp/cli.pdf");
+ expect(isWritablePath("/tmp/cli.pdf")).toBe(true);
+ });
+
+ it("file outside any directory root is not writable", () => {
+ allowedLocalDirs.add("/home/user/docs");
+ expect(isWritablePath("/home/user/other/file.pdf")).toBe(false);
+ expect(isWritablePath("/home/user/docsevil/file.pdf")).toBe(false);
+ });
+
+ it("dir root named 'uploads' is read-only by default", () => {
+ // Claude Desktop mounts the conversation's attachment drop folder as a
+ // directory root literally named 'uploads'. The attached PDF lives
+ // directly under it.
+ allowedLocalDirs.add("/var/folders/xy/T/claude/uploads");
+ expect(isWritablePath("/var/folders/xy/T/claude/uploads/Form.pdf")).toBe(
+ false,
+ );
+ // Deep nesting under the uploads root โ still the same root, still no.
+ expect(
+ isWritablePath("/var/folders/xy/T/claude/uploads/sub/deep.pdf"),
+ ).toBe(false);
+ });
+
+ it("uploads-root guard matches basename, not substring", () => {
+ allowedLocalDirs.add("/home/user/my-uploads"); // contains 'uploads' but โ
+ allowedLocalDirs.add("/home/user/uploads-archive");
+ expect(isWritablePath("/home/user/my-uploads/f.pdf")).toBe(true);
+ expect(isWritablePath("/home/user/uploads-archive/f.pdf")).toBe(true);
+ });
+
+ it("--writeable-uploads-root opts back in", () => {
+ allowedLocalDirs.add("/var/folders/xy/T/claude/uploads");
+ writeFlags.allowUploadsRoot = true;
+ expect(isWritablePath("/var/folders/xy/T/claude/uploads/Form.pdf")).toBe(
+ true,
+ );
+ });
+
+ it("CLI file under an uploads root is still writable", () => {
+ // Explicit CLI intent beats the uploads-basename heuristic.
+ allowedLocalDirs.add("/tmp/uploads");
+ allowedLocalFiles.add("/tmp/uploads/explicit.pdf");
+ cliLocalFiles.add("/tmp/uploads/explicit.pdf");
+ expect(isWritablePath("/tmp/uploads/explicit.pdf")).toBe(true);
+ });
+});
+
+describe("file watching", () => {
+ let tmpDir: string;
+ let tmpFile: string;
+ const uuid = "test-watch-uuid";
+
+ // Long-poll timeout is 30s โ tests that poll must complete sooner.
+ const pollWithTimeout = async (
+ client: Client,
+ timeoutMs = 5000,
+ ): Promise<{ type: string; mtimeMs?: number }[]> => {
+ const result = await Promise.race([
+ client.callTool({
+ name: "poll_pdf_commands",
+ arguments: { viewUUID: uuid },
+ }),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error("poll timeout")), timeoutMs),
+ ),
+ ]);
+ return (
+ ((result as { structuredContent?: { commands?: unknown[] } })
+ .structuredContent?.commands as { type: string; mtimeMs?: number }[]) ??
+ []
+ );
+ };
+
+ beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pdf-watch-"));
+ tmpFile = path.join(tmpDir, "test.pdf");
+ fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%test\n"));
+ allowedLocalFiles.add(tmpFile);
+ cliLocalFiles.add(tmpFile); // save_pdf test needs write scope
+ });
+
+ afterEach(() => {
+ stopFileWatch(uuid);
+ allowedLocalFiles.delete(tmpFile);
+ cliLocalFiles.delete(tmpFile);
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ it("enqueues file_changed after external write", async () => {
+ const server = createServer({ enableInteract: true });
+ const client = new Client({ name: "t", version: "1" });
+ const [ct, st] = InMemoryTransport.createLinkedPair();
+ await Promise.all([server.connect(st), client.connect(ct)]);
+
+ startFileWatch(uuid, tmpFile);
+ await new Promise((r) => setTimeout(r, 50)); // let watcher settle
+
+ fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%changed\n"));
+
+ const cmds = await pollWithTimeout(client);
+ expect(cmds).toHaveLength(1);
+ expect(cmds[0].type).toBe("file_changed");
+ expect(cmds[0].mtimeMs).toBeGreaterThan(0);
+
+ await client.close();
+ await server.close();
+ });
+
+ it("debounces rapid writes into one command", async () => {
+ const server = createServer({ enableInteract: true });
+ const client = new Client({ name: "t", version: "1" });
+ const [ct, st] = InMemoryTransport.createLinkedPair();
+ await Promise.all([server.connect(st), client.connect(ct)]);
+
+ startFileWatch(uuid, tmpFile);
+ await new Promise((r) => setTimeout(r, 50));
+
+ fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%a\n"));
+ fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%b\n"));
+ fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%c\n"));
+
+ const cmds = await pollWithTimeout(client);
+ expect(cmds).toHaveLength(1);
+
+ await client.close();
+ await server.close();
+ });
+
+ it("stopFileWatch prevents further commands", async () => {
+ const server = createServer({ enableInteract: true });
+ const client = new Client({ name: "t", version: "1" });
+ const [ct, st] = InMemoryTransport.createLinkedPair();
+ await Promise.all([server.connect(st), client.connect(ct)]);
+
+ startFileWatch(uuid, tmpFile);
+ await new Promise((r) => setTimeout(r, 50));
+ stopFileWatch(uuid);
+
+ fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%x\n"));
+
+ // Debounce window + margin โ no event should fire
+ await new Promise((r) => setTimeout(r, 300));
+
+ // Poll should block (long-poll) โ timeout here means no command was queued
+ await expect(pollWithTimeout(client, 500)).rejects.toThrow("poll timeout");
+
+ await client.close();
+ await server.close();
+ });
+
+ it("save_pdf returns mtimeMs in structuredContent", async () => {
+ const server = createServer({ enableInteract: true });
+ const client = new Client({ name: "t", version: "1" });
+ const [ct, st] = InMemoryTransport.createLinkedPair();
+ await Promise.all([server.connect(st), client.connect(ct)]);
+
+ const before = fs.statSync(tmpFile).mtimeMs;
+ // Ensure mtime will differ on coarse-granularity filesystems
+ await new Promise((r) => setTimeout(r, 10));
+
+ const r = await client.callTool({
+ name: "save_pdf",
+ arguments: {
+ url: tmpFile,
+ data: Buffer.from("%PDF-1.4\nnew").toString("base64"),
+ },
+ });
+ expect(r.isError).toBeFalsy();
+ const sc = r.structuredContent as { filePath: string; mtimeMs: number };
+ expect(sc.filePath).toBe(tmpFile);
+ expect(sc.mtimeMs).toBeGreaterThanOrEqual(before);
+
+ await client.close();
+ await server.close();
+ });
+
+ it("save_pdf refuses file roots from MCP client (not CLI)", async () => {
+ // Simulate: file is readable (in allowedLocalFiles via refreshRoots)
+ // but NOT in cliLocalFiles โ it came from the client, not a CLI arg.
+ cliLocalFiles.delete(tmpFile);
+
+ const server = createServer({ enableInteract: true });
+ const client = new Client({ name: "t", version: "1" });
+ const [ct, st] = InMemoryTransport.createLinkedPair();
+ await Promise.all([server.connect(st), client.connect(ct)]);
+
+ const original = fs.readFileSync(tmpFile);
+ const r = await client.callTool({
+ name: "save_pdf",
+ arguments: {
+ url: tmpFile,
+ data: Buffer.from("%PDF-1.4\nshould-not-write").toString("base64"),
+ },
+ });
+ expect(r.isError).toBe(true);
+ const text = (r.content as { text: string }[])[0].text;
+ expect(text).toContain("read-only");
+ // Verify the file was NOT modified
+ expect(fs.readFileSync(tmpFile)).toEqual(original);
+
+ await client.close();
+ await server.close();
+ });
+
+ it("save_pdf allows files under a directory root", async () => {
+ // File is under a mounted directory root โ but NOT itself a file root
+ // (a file root, even under a mounted dir, is read-only per isWritablePath).
+ cliLocalFiles.delete(tmpFile);
+ allowedLocalFiles.delete(tmpFile);
+ allowedLocalDirs.add(tmpDir);
+
+ const server = createServer({ enableInteract: true });
+ const client = new Client({ name: "t", version: "1" });
+ const [ct, st] = InMemoryTransport.createLinkedPair();
+ await Promise.all([server.connect(st), client.connect(ct)]);
+
+ const r = await client.callTool({
+ name: "save_pdf",
+ arguments: {
+ url: tmpFile,
+ data: Buffer.from("%PDF-1.4\nvia-dir-root").toString("base64"),
+ },
+ });
+ expect(r.isError).toBeFalsy();
+ expect(fs.readFileSync(tmpFile, "utf8")).toBe("%PDF-1.4\nvia-dir-root");
+
+ allowedLocalDirs.delete(tmpDir);
+ await client.close();
+ await server.close();
+ });
+
+ // fs.watch on a file that gets replaced via rename: on macOS (kqueue)
+ // the watcher reliably fires a "rename" event which our re-attach logic
+ // handles. On Linux (inotify), a watcher on the old inode often gets no
+ // event at all โ inotify watches inodes, and the rename just atomically
+ // swaps the directory entry to a NEW inode. Directory-level watching
+ // would fix this but isn't what we do. Skip on non-darwin.
+ it.skipIf(process.platform !== "darwin")(
+ "detects atomic rename (macOS kqueue only)",
+ async () => {
+ const server = createServer({ enableInteract: true });
+ const client = new Client({ name: "t", version: "1" });
+ const [ct, st] = InMemoryTransport.createLinkedPair();
+ await Promise.all([server.connect(st), client.connect(ct)]);
+
+ startFileWatch(uuid, tmpFile);
+ await new Promise((r) => setTimeout(r, 50));
+
+ // Simulate vim/vscode: write to temp, rename over original
+ const tmpWrite = tmpFile + ".swp";
+ fs.writeFileSync(tmpWrite, Buffer.from("%PDF-1.4\n%atomic\n"));
+ fs.renameSync(tmpWrite, tmpFile);
+
+ const cmds = await pollWithTimeout(client);
+ expect(cmds).toHaveLength(1);
+ expect(cmds[0].type).toBe("file_changed");
+
+ await client.close();
+ await server.close();
+ },
+ );
+});
+
+describe("interact tool", () => {
+ // Helper: connected server+client pair with interact enabled.
+ // Command queues are MODULE-LEVEL (shared across server instances), so each
+ // test uses a distinct viewUUID to avoid cross-test interference.
+ async function connect() {
+ const server = createServer({ enableInteract: true });
+ const client = new Client({ name: "t", version: "1" });
+ const [ct, st] = InMemoryTransport.createLinkedPair();
+ await Promise.all([server.connect(st), client.connect(ct)]);
+ return { server, client };
+ }
+
+ // Helper: poll with an outer deadline so a failing test doesn't hang for the
+ // full 30s long-poll. Safe ONLY when a command is already enqueued โ poll
+ // then returns after the 200ms batch window.
+ async function poll(client: Client, uuid: string, timeoutMs = 2000) {
+ const result = await Promise.race([
+ client.callTool({
+ name: "poll_pdf_commands",
+ arguments: { viewUUID: uuid },
+ }),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error("poll timeout")), timeoutMs),
+ ),
+ ]);
+ return ((result as { structuredContent?: { commands?: unknown[] } })
+ .structuredContent?.commands ?? []) as Array>;
+ }
+
+ function firstText(r: Awaited>): string {
+ return (r.content as Array<{ type: string; text: string }>)[0].text;
+ }
+
+ it("enqueue โ poll roundtrip delivers the command", async () => {
+ const { server, client } = await connect();
+ const uuid = "test-interact-roundtrip";
+
+ const r = await client.callTool({
+ name: "interact",
+ arguments: { viewUUID: uuid, action: "navigate", page: 5 },
+ });
+ expect(r.isError).toBeFalsy();
+ expect(firstText(r)).toContain("Queued");
+ expect(firstText(r)).toContain("page 5");
+
+ // Core mechanism: the viewer polls for what the model enqueued.
+ const cmds = await poll(client, uuid);
+ expect(cmds).toHaveLength(1);
+ expect(cmds[0].type).toBe("navigate");
+ expect(cmds[0].page).toBe(5);
+
+ await client.close();
+ await server.close();
+ });
+
+ it("navigate without `page` returns isError with a helpful message", async () => {
+ const { server, client } = await connect();
+
+ const r = await client.callTool({
+ name: "interact",
+ arguments: { viewUUID: "test-err-nav", action: "navigate" },
+ });
+ expect(r.isError).toBe(true);
+ expect(firstText(r)).toContain("navigate");
+ expect(firstText(r)).toContain("page");
+
+ await client.close();
+ await server.close();
+ });
+
+ it("fill_form without `fields` returns isError with a helpful message", async () => {
+ const { server, client } = await connect();
+
+ const r = await client.callTool({
+ name: "interact",
+ arguments: { viewUUID: "test-err-fill", action: "fill_form" },
+ });
+ expect(r.isError).toBe(true);
+ expect(firstText(r)).toContain("fill_form");
+ expect(firstText(r)).toContain("fields");
+
+ await client.close();
+ await server.close();
+ });
+
+ it("add_annotations without `annotations` returns isError with a helpful message", async () => {
+ const { server, client } = await connect();
+
+ const r = await client.callTool({
+ name: "interact",
+ arguments: { viewUUID: "test-err-ann", action: "add_annotations" },
+ });
+ expect(r.isError).toBe(true);
+ expect(firstText(r)).toContain("add_annotations");
+ expect(firstText(r)).toContain("annotations");
+
+ await client.close();
+ await server.close();
+ });
+
+ it("isolates command queues across distinct viewUUIDs", async () => {
+ const { server, client } = await connect();
+ const uuidA = "test-isolate-A";
+ const uuidB = "test-isolate-B";
+
+ await client.callTool({
+ name: "interact",
+ arguments: { viewUUID: uuidA, action: "navigate", page: 3 },
+ });
+ await client.callTool({
+ name: "interact",
+ arguments: { viewUUID: uuidB, action: "search", query: "quantum" },
+ });
+
+ const cmdsA = await poll(client, uuidA);
+ expect(cmdsA).toHaveLength(1);
+ expect(cmdsA[0].type).toBe("navigate");
+ expect(cmdsA[0].page).toBe(3);
+
+ const cmdsB = await poll(client, uuidB);
+ expect(cmdsB).toHaveLength(1);
+ expect(cmdsB[0].type).toBe("search");
+ expect(cmdsB[0].query).toBe("quantum");
+
+ await client.close();
+ await server.close();
+ });
+
+ // SKIPPED: the unknown-UUID path enters the long-poll branch and blocks for
+ // the full LONG_POLL_TIMEOUT_MS (30s, module-local const, not configurable).
+ // The handler does dequeue [] at the end, so the return value IS
+ // {commands: []} โ but there's no fast path to reach it without waiting.
+ // See the `stopFileWatch prevents further commands` test above for indirect
+ // coverage of the same blocking behaviour.
+ it.skip("poll with unknown viewUUID returns {commands: []} after long-poll", () => {});
+
+ it("fill_form passes all fields through when viewFieldNames is not registered", async () => {
+ const { server, client } = await connect();
+ // Fresh UUID never seen by display_pdf โ viewFieldNames.get(uuid) is
+ // undefined โ the known-fields guard (`knownFields && !knownFields.has()`)
+ // is falsy for every field โ everything is enqueued.
+ const uuid = "test-fillform-passthrough";
+
+ const r = await client.callTool({
+ name: "interact",
+ arguments: {
+ viewUUID: uuid,
+ action: "fill_form",
+ fields: [
+ { name: "anything", value: "goes" },
+ { name: "unchecked", value: true },
+ ],
+ },
+ });
+ expect(r.isError).toBeFalsy();
+ expect(firstText(r)).toContain("Filled 2 field(s)");
+ // No rejection complaint โ the registry has no entry for this UUID
+ expect(firstText(r)).not.toContain("Unknown");
+
+ const cmds = await poll(client, uuid);
+ expect(cmds).toHaveLength(1);
+ expect(cmds[0].type).toBe("fill_form");
+ const fields = cmds[0].fields as Array<{ name: string; value: unknown }>;
+ expect(fields).toHaveLength(2);
+ expect(fields.map((f) => f.name).sort()).toEqual(["anything", "unchecked"]);
+
+ // Note: the "registered โ reject unknown" branch needs viewFieldNames
+ // populated, which only happens inside display_pdf (requires a real PDF).
+ // That map isn't exported, so the rejection path is covered by e2e only.
+
+ await client.close();
+ await server.close();
+ });
+
+ // SECURITY: resolveImageAnnotation must not read arbitrary local files.
+ // The model controls imageUrl; without validation it's an exfil primitive
+ // (readFile โ base64 โ iframe โ get_screenshot reads it back).
+ describe("add_annotations image: imageUrl validation", () => {
+ let savedDirs: Set;
+ beforeEach(() => {
+ savedDirs = new Set(allowedLocalDirs);
+ allowedLocalDirs.clear();
+ });
+ afterEach(() => {
+ allowedLocalDirs.clear();
+ for (const d of savedDirs) allowedLocalDirs.add(d);
+ });
+
+ it("rejects local path outside allowed roots", async () => {
+ const { server, client } = await connect();
+ // Whitelist a harmless temp dir; target a path clearly outside it.
+ allowedLocalDirs.add(os.tmpdir());
+ const target = path.join(os.homedir(), ".ssh", "id_rsa");
+ const r = await client.callTool({
+ name: "interact",
+ arguments: {
+ viewUUID: "sec-local",
+ action: "add_annotations",
+ annotations: [{ type: "image", id: "i1", page: 1, imageUrl: target }],
+ },
+ });
+ expect(r.isError).toBe(true);
+ expect(firstText(r)).toContain("imageUrl rejected");
+ await client.close();
+ await server.close();
+ });
+
+ it("rejects http:// URL (SSRF)", async () => {
+ const { server, client } = await connect();
+ const r = await client.callTool({
+ name: "interact",
+ arguments: {
+ viewUUID: "sec-http",
+ action: "add_annotations",
+ annotations: [
+ {
+ type: "image",
+ id: "i1",
+ page: 1,
+ imageUrl: "http://169.254.169.254/latest/meta-data/",
+ },
+ ],
+ },
+ });
+ expect(r.isError).toBe(true);
+ expect(firstText(r)).toContain("imageUrl rejected");
+ await client.close();
+ await server.close();
+ });
+
+ it("accepts path under an allowed dir (reaches readFile)", async () => {
+ const { server, client } = await connect();
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pdf-imgurl-"));
+ allowedLocalDirs.add(dir);
+ // Minimal valid PNG (1x1 transparent): 8-byte sig + IHDR + IDAT + IEND.
+ const png = Buffer.from(
+ "89504E470D0A1A0A0000000D49484452000000010000000108060000001F15C4890000000A49444154789C6360000000000200015E9AFE400000000049454E44AE426082",
+ "hex",
+ );
+ const imgPath = path.join(dir, "sig.png");
+ fs.writeFileSync(imgPath, png);
+ try {
+ const r = await client.callTool({
+ name: "interact",
+ arguments: {
+ viewUUID: "sec-ok",
+ action: "add_annotations",
+ annotations: [
+ { type: "image", id: "i1", page: 1, imageUrl: imgPath },
+ ],
+ },
+ });
+ // No security rejection; readFile succeeds; command enqueued.
+ expect(r.isError).toBeFalsy();
+ const cmds = await poll(client, "sec-ok");
+ expect(cmds).toHaveLength(1);
+ expect(cmds[0].type).toBe("add_annotations");
+ const anns = cmds[0].annotations as Array>;
+ // validateUrl passed โ readFile ran โ imageData populated.
+ expect(typeof anns[0].imageData).toBe("string");
+ expect((anns[0].imageData as string).length).toBeGreaterThan(0);
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ await client.close();
+ await server.close();
+ });
+ });
+
+ describe("save_as", () => {
+ // Roundtrip tests need: writable scope, kick off interact WITHOUT awaiting
+ // (it blocks until the view replies), poll โ submit โ await. The poll()
+ // call also registers the uuid in viewsPolled, satisfying
+ // ensureViewerIsPolling โ without it interact would hang ~8s and fail.
+
+ let tmpDir: string;
+ let savedDirs: Set;
+
+ beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pdf-saveas-"));
+ savedDirs = new Set(allowedLocalDirs);
+ allowedLocalDirs.add(tmpDir); // make tmpDir a directory root โ writable
+ });
+
+ afterEach(() => {
+ allowedLocalDirs.clear();
+ for (const x of savedDirs) allowedLocalDirs.add(x);
+ viewSourcePaths.clear();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ it("no path, no source tracked โ tells model to provide a path", async () => {
+ // Fresh UUID never seen by display_pdf โ viewSourcePaths has no entry.
+ // Same condition as a remote (https://) PDF or a stale viewUUID.
+ const { server, client } = await connect();
+ const r = await client.callTool({
+ name: "interact",
+ arguments: { viewUUID: "saveas-nosource", action: "save_as" },
+ });
+ expect(r.isError).toBe(true);
+ expect(firstText(r)).toContain("no local source file");
+ expect(firstText(r)).toContain("Provide an explicit `path`");
+ await client.close();
+ await server.close();
+ });
+
+ it("no path, source tracked, overwrite omitted โ asks for confirmation", async () => {
+ const { server, client } = await connect();
+ const source = path.join(tmpDir, "original.pdf");
+ fs.writeFileSync(source, "%PDF-1.4\noriginal");
+ viewSourcePaths.set("saveas-noconfirm", source);
+
+ const r = await client.callTool({
+ name: "interact",
+ arguments: { viewUUID: "saveas-noconfirm", action: "save_as" },
+ });
+ expect(r.isError).toBe(true);
+ expect(firstText(r)).toContain("overwrites the original");
+ expect(firstText(r)).toContain(source);
+ expect(firstText(r)).toContain("overwrite: true");
+ // Nothing enqueued, file untouched
+ expect(fs.readFileSync(source, "utf8")).toBe("%PDF-1.4\noriginal");
+ await client.close();
+ await server.close();
+ });
+
+ it("no path, source not writable โ same gate as save button", async () => {
+ const { server, client } = await connect();
+ // Source outside any directory root โ isWritablePath false โ save button
+ // would be hidden in the viewer. save_as should refuse for the same reason.
+ const outside = path.join(os.tmpdir(), "saveas-outside.pdf");
+ fs.writeFileSync(outside, "x");
+ viewSourcePaths.set("saveas-buttongate", outside);
+
+ try {
+ const r = await client.callTool({
+ name: "interact",
+ arguments: {
+ viewUUID: "saveas-buttongate",
+ action: "save_as",
+ overwrite: true,
+ },
+ });
+ expect(r.isError).toBe(true);
+ expect(firstText(r)).toContain("not writable");
+ expect(firstText(r)).toContain("save button is hidden");
+ } finally {
+ fs.rmSync(outside, { force: true });
+ }
+ await client.close();
+ await server.close();
+ });
+
+ it("no path, overwrite: true โ roundtrip overwrites the original", async () => {
+ const { server, client } = await connect();
+ const uuid = "saveas-original";
+ const source = path.join(tmpDir, "report.pdf");
+ fs.writeFileSync(source, "%PDF-1.4\noriginal contents");
+ viewSourcePaths.set(uuid, source);
+
+ const interactPromise = client.callTool({
+ name: "interact",
+ arguments: { viewUUID: uuid, action: "save_as", overwrite: true },
+ });
+
+ const cmds = await poll(client, uuid);
+ expect(cmds).toHaveLength(1);
+ expect(cmds[0].type).toBe("save_as");
+ await client.callTool({
+ name: "submit_save_data",
+ arguments: {
+ requestId: cmds[0].requestId as string,
+ data: Buffer.from("%PDF-1.4\nannotated").toString("base64"),
+ },
+ });
+
+ const r = await interactPromise;
+ expect(r.isError).toBeFalsy();
+ expect(firstText(r)).toContain(source);
+ expect(fs.readFileSync(source, "utf8")).toBe("%PDF-1.4\nannotated");
+
+ await client.close();
+ await server.close();
+ });
+
+ it("rejects non-absolute path", async () => {
+ const { server, client } = await connect();
+ const r = await client.callTool({
+ name: "interact",
+ arguments: {
+ viewUUID: "saveas-rel",
+ action: "save_as",
+ path: "relative.pdf",
+ },
+ });
+ expect(r.isError).toBe(true);
+ expect(firstText(r)).toContain("absolute");
+ await client.close();
+ await server.close();
+ });
+
+ it("rejects non-writable path", async () => {
+ const { server, client } = await connect();
+ // Path outside any directory root โ not writable. Validation is sync,
+ // so nothing is enqueued and the queue stays empty.
+ const r = await client.callTool({
+ name: "interact",
+ arguments: {
+ viewUUID: "saveas-nowrite",
+ action: "save_as",
+ path: "/somewhere/else/out.pdf",
+ },
+ });
+ expect(r.isError).toBe(true);
+ expect(firstText(r)).toContain("not under a mounted directory root");
+ await client.close();
+ await server.close();
+ });
+
+ it("rejects existing file when overwrite is false (default)", async () => {
+ const { server, client } = await connect();
+ const target = path.join(tmpDir, "exists.pdf");
+ fs.writeFileSync(target, "old contents");
+
+ const r = await client.callTool({
+ name: "interact",
+ arguments: {
+ viewUUID: "saveas-exists",
+ action: "save_as",
+ path: target,
+ },
+ });
+ expect(r.isError).toBe(true);
+ expect(firstText(r)).toContain("already exists");
+ expect(firstText(r)).toContain("overwrite: true");
+ // Existence check is sync โ nothing enqueued, file untouched.
+ expect(fs.readFileSync(target, "utf8")).toBe("old contents");
+ await client.close();
+ await server.close();
+ });
+
+ it("full roundtrip: enqueue โ poll โ submit โ file written", async () => {
+ const { server, client } = await connect();
+ const uuid = "saveas-roundtrip";
+ const target = path.join(tmpDir, "out.pdf");
+ const pdfBytes = "%PDF-1.4\nfake-annotated-contents\n%%EOF";
+
+ // interact blocks in waitForSaveData until submit_save_data resolves it
+ const interactPromise = client.callTool({
+ name: "interact",
+ arguments: { viewUUID: uuid, action: "save_as", path: target },
+ });
+
+ // Viewer polls โ receives the save_as command with a requestId
+ const cmds = await poll(client, uuid);
+ expect(cmds).toHaveLength(1);
+ expect(cmds[0].type).toBe("save_as");
+ const requestId = cmds[0].requestId as string;
+ expect(typeof requestId).toBe("string");
+
+ // Viewer submits bytes
+ const submit = await client.callTool({
+ name: "submit_save_data",
+ arguments: {
+ requestId,
+ data: Buffer.from(pdfBytes).toString("base64"),
+ },
+ });
+ expect(submit.isError).toBeFalsy();
+
+ // interact now unblocks with success
+ const r = await interactPromise;
+ expect(r.isError).toBeFalsy();
+ expect(firstText(r)).toContain("Saved");
+ expect(firstText(r)).toContain(target);
+ expect(fs.readFileSync(target, "utf8")).toBe(pdfBytes);
+
+ await client.close();
+ await server.close();
+ });
+
+ it("overwrite: true replaces an existing file", async () => {
+ const { server, client } = await connect();
+ const uuid = "saveas-overwrite";
+ const target = path.join(tmpDir, "replace.pdf");
+ fs.writeFileSync(target, "old contents");
+
+ const interactPromise = client.callTool({
+ name: "interact",
+ arguments: {
+ viewUUID: uuid,
+ action: "save_as",
+ path: target,
+ overwrite: true,
+ },
+ });
+
+ const cmds = await poll(client, uuid);
+ const requestId = cmds[0].requestId as string;
+ await client.callTool({
+ name: "submit_save_data",
+ arguments: {
+ requestId,
+ data: Buffer.from("%PDF-1.4\nnew").toString("base64"),
+ },
+ });
+
+ const r = await interactPromise;
+ expect(r.isError).toBeFalsy();
+ expect(fs.readFileSync(target, "utf8")).toBe("%PDF-1.4\nnew");
+
+ await client.close();
+ await server.close();
+ });
+
+ it("propagates viewer-reported errors to the model", async () => {
+ const { server, client } = await connect();
+ const uuid = "saveas-viewerr";
+ const target = path.join(tmpDir, "wontwrite.pdf");
+
+ const interactPromise = client.callTool({
+ name: "interact",
+ arguments: { viewUUID: uuid, action: "save_as", path: target },
+ });
+
+ const cmds = await poll(client, uuid);
+ // Viewer hit an error building bytes โ reports it instead of timing out
+ await client.callTool({
+ name: "submit_save_data",
+ arguments: {
+ requestId: cmds[0].requestId as string,
+ error: "pdf-lib choked on a comb field",
+ },
+ });
+
+ const r = await interactPromise;
+ expect(r.isError).toBe(true);
+ expect(firstText(r)).toContain("pdf-lib choked on a comb field");
+ expect(fs.existsSync(target)).toBe(false);
+
+ await client.close();
+ await server.close();
+ });
+
+ it("submit_save_data with unknown requestId returns isError", async () => {
+ const { server, client } = await connect();
+ const r = await client.callTool({
+ name: "submit_save_data",
+ arguments: { requestId: "never-created", data: "AAAA" },
+ });
+ expect(r.isError).toBe(true);
+ expect(firstText(r)).toContain("No pending request");
+ await client.close();
+ await server.close();
+ });
+ });
+
+ describe("viewer liveness", () => {
+ // get_screenshot/get_text fail fast when the iframe never polled, instead
+ // of waiting 45s for a viewer that isn't there. Reproduces the case where
+ // the host goes idle before the iframe reaches startPolling().
+
+ it("get_screenshot fails fast when viewer never polled", async () => {
+ const { server, client } = await connect();
+ const uuid = "never-polled-screenshot";
+
+ const started = Date.now();
+ const r = await client.callTool({
+ name: "interact",
+ arguments: { viewUUID: uuid, action: "get_screenshot", page: 1 },
+ });
+ const elapsed = Date.now() - started;
+
+ expect(r.isError).toBe(true);
+ expect(firstText(r)).toContain("never connected");
+ expect(firstText(r)).toContain(uuid);
+ expect(firstText(r)).toContain("display_pdf again"); // recovery hint
+ // Fast-fail bound (~8s grace), well under the 45s page-data timeout.
+ // 15s upper bound leaves slack for CI scheduling.
+ expect(elapsed).toBeLessThan(15_000);
+
+ await client.close();
+ await server.close();
+ }, 20_000);
+
+ it("get_screenshot waits full timeout when viewer polled then went silent", async () => {
+ // Viewer polled once (proving it mounted) then hung on a heavy render.
+ // The grace check passes, so we fall through to the 45s page-data wait โ
+ // verified here by racing against a 12s deadline that should NOT win.
+ const { server, client } = await connect();
+ const uuid = "polled-then-silent";
+
+ // Viewer's first poll: drain whatever's there so it returns fast.
+ // Enqueue a trivial command first so poll returns via the batch-wait
+ // path (~200ms) instead of blocking on the 30s long-poll.
+ await client.callTool({
+ name: "interact",
+ arguments: { viewUUID: uuid, action: "navigate", page: 1 },
+ });
+ await poll(client, uuid);
+
+ // Now get_screenshot โ viewer has polled, so no fast-fail. But viewer
+ // never calls submit_page_data โ should wait beyond the grace period.
+ const outcome = await Promise.race([
+ client
+ .callTool({
+ name: "interact",
+ arguments: { viewUUID: uuid, action: "get_screenshot", page: 1 },
+ })
+ .then(() => "completed" as const),
+ new Promise<"still-waiting">((r) =>
+ setTimeout(() => r("still-waiting"), 12_000),
+ ),
+ ]);
+
+ expect(outcome).toBe("still-waiting");
+
+ await client.close();
+ await server.close();
+ }, 20_000);
+
+ it("get_screenshot succeeds when viewer polls during grace window", async () => {
+ // Model calls interact before the viewer has polled โ but the viewer
+ // shows up within the grace period and completes the roundtrip.
+ const { server, client } = await connect();
+ const uuid = "late-arriving-viewer";
+
+ const interactPromise = client.callTool({
+ name: "interact",
+ arguments: { viewUUID: uuid, action: "get_screenshot", page: 1 },
+ });
+
+ // Viewer connects 500ms late โ well inside the grace window.
+ await new Promise((r) => setTimeout(r, 500));
+ const cmds = await poll(client, uuid);
+ const getPages = cmds.find((c) => c.type === "get_pages");
+ expect(getPages).toBeDefined();
+
+ // Viewer responds with the page data.
+ await client.callTool({
+ name: "submit_page_data",
+ arguments: {
+ requestId: getPages!.requestId as string,
+ pages: [
+ { page: 1, image: Buffer.from("fake-jpeg").toString("base64") },
+ ],
+ },
+ });
+
+ const r = await interactPromise;
+ expect(r.isError).toBeFalsy();
+ expect((r.content as Array<{ type: string }>)[0].type).toBe("image");
+
+ await client.close();
+ await server.close();
+ }, 15_000);
+
+ it("batch step failure: 1:1 content, no isError, ERROR-prefixed slot", async () => {
+ // LocalAgentMode SDK 2.1.87 collapses isError:true results to a bare
+ // string of content[0].text โ drops images from earlier successful
+ // steps. So we don't set isError on a step failure: each command gets
+ // one content slot, the failed one starts with "ERROR", and the batch
+ // stops there. content.length tells the model how far it got.
+ const { server, client } = await connect();
+ const uuid = "batch-error-ordering";
+
+ const r = await client.callTool({
+ name: "interact",
+ arguments: {
+ viewUUID: uuid,
+ commands: [
+ { action: "navigate", page: 3 }, // succeeds โ "Queued: ..."
+ { action: "get_screenshot", page: 1 }, // never-polled โ fast-fail
+ { action: "navigate", page: 5 }, // never reached
+ ],
+ },
+ });
+
+ // Not isError โ that would trigger the SDK flatten
+ expect(r.isError).toBeFalsy();
+ const content = r.content as Array<{ type: string; text?: string }>;
+ // Stopped at step 2; step 3 never ran
+ expect(content).toHaveLength(2);
+ // Slot 0: step 1's success, untouched
+ expect(content[0].text).toContain("Queued");
+ expect(content[0].text).not.toMatch(/^ERROR/);
+ // Slot 1: step 2's failure, ERROR-prefixed with the actual message
+ expect(content[1].text).toMatch(/^ERROR/);
+ expect(content[1].text).toContain("2/3");
+ expect(content[1].text).toContain("get_screenshot");
+ expect(content[1].text).toContain("never connected");
+
+ await client.close();
+ await server.close();
+ }, 15_000);
+ });
+});
diff --git a/examples/pdf-server/server.ts b/examples/pdf-server/server.ts
index ccc400f..e67cd47 100644
--- a/examples/pdf-server/server.ts
+++ b/examples/pdf-server/server.ts
@@ -13,18 +13,56 @@
import { randomUUID } from "crypto";
import fs from "node:fs";
import path from "node:path";
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
registerAppResource,
registerAppTool,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import {
- RootsListChangedNotificationSchema,
+ McpServer,
type CallToolResult,
+ type ElicitResult,
+ type PrimitiveSchemaDefinition,
type ReadResourceResult,
-} from "@modelcontextprotocol/sdk/types.js";
+ type Server,
+} from "@modelcontextprotocol/server";
+// Stub DOMMatrix/ImageData/Path2D before pdfjs-dist loads โ its legacy
+// build instantiates DOMMatrix at module scope and the @napi-rs/canvas
+// polyfill is unreliable under npx. See ./pdfjs-polyfill.ts for details.
+import "./pdfjs-polyfill.js";
+import {
+ getDocument,
+ PDFDataRangeTransport,
+ VerbosityLevel,
+ version as PDFJS_VERSION,
+} from "pdfjs-dist/legacy/build/pdf.mjs";
+import type { PDFDocumentProxy } from "pdfjs-dist/types/src/display/api.js";
+
+/**
+ * PDF Standard-14 fonts from CDN. Used by both server and viewer so we
+ * declare a single well-known origin in CSP connectDomains.
+ *
+ * pdf.js in Node defaults to NodeStandardFontDataFactory (fs.readFile) which
+ * can't fetch URLs, so we pass {@link FetchStandardFontDataFactory} alongside.
+ * The browser viewer uses the DOM factory by default and just needs the URL.
+ */
+export const STANDARD_FONT_DATA_URL = `https://unpkg.com/pdfjs-dist@${PDFJS_VERSION}/standard_fonts/`;
+const STANDARD_FONT_ORIGIN = "https://unpkg.com";
+
+/** pdf.js font factory that uses fetch() instead of fs.readFile. */
+class FetchStandardFontDataFactory {
+ baseUrl: string | null;
+ constructor({ baseUrl = null }: { baseUrl?: string | null }) {
+ this.baseUrl = baseUrl;
+ }
+ async fetch({ filename }: { filename: string }): Promise {
+ if (!this.baseUrl) throw new Error("standardFontDataUrl not provided");
+ const url = `${this.baseUrl}${filename}`;
+ const res = await globalThis.fetch(url);
+ if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
+ return new Uint8Array(await res.arrayBuffer());
+ }
+}
import { z } from "zod";
// =============================================================================
@@ -44,17 +82,442 @@ export const CACHE_MAX_LIFETIME_MS = 60_000; // 60 seconds
/** Max size for cached PDFs (defensive limit to prevent memory exhaustion) */
export const CACHE_MAX_PDF_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
-/** Allowed local file paths (populated from CLI args) */
+/** Max total bytes across all cache entries; oldest evicted first when exceeded. */
+export const CACHE_MAX_TOTAL_BYTES = 256 * 1024 * 1024; // 256MB
+
+/** Allowed local file paths (CLI args + file roots โ read access). */
export const allowedLocalFiles = new Set();
-/** Allowed local directories (populated from MCP roots) */
+/** Allowed local directories (CLI args + directory roots โ read access). */
export const allowedLocalDirs = new Set();
+/**
+ * Subset of allowedLocalFiles that came from CLI args (not MCP roots).
+ * Only these individual files are writable. File roots from the client
+ * are uploaded copies in ad-hoc hidden folders โ treat as read-only.
+ * Directory roots are mounted folders; files UNDER them are writable.
+ */
+export const cliLocalFiles = new Set();
+
+/**
+ * Write-permission flags. Object wrapper (not a bare `let`) so main.ts can
+ * mutate via the exported binding without re-import gymnastics โ same
+ * pattern as the Sets above.
+ */
+export const writeFlags = {
+ /**
+ * Claude Desktop mounts its per-conversation drop folder as a directory
+ * root whose basename is literally `uploads`. Files in there are one-shot
+ * copies the client doesn't expect us to overwrite. Default: read-only.
+ * `--writeable-uploads-root` flips this for local testing.
+ */
+ allowUploadsRoot: false,
+};
+
+/**
+ * Saving is allowed iff:
+ * (a) the file was passed as a CLI arg โ the user explicitly named it
+ * when starting the server, so overwriting is clearly intentional; OR
+ * (b) the file is STRICTLY UNDER a directory root at any depth
+ * (isAncestorDir excludes rel === "", so the root itself never
+ * counts), AND the client did not ALSO send it as a file root.
+ * A file root is the client's way of saying "here's an upload" โ
+ * treat that signal as authoritative even when the path happens
+ * to fall inside a mounted directory.
+ *
+ * EXCEPTION to (b): a dir root whose basename is `uploads` is treated
+ * as read-only unless `writeFlags.allowUploadsRoot` is set. This is how
+ * Claude Desktop surfaces attached files โ writing back to them
+ * surprises the user (the attachment doesn't update).
+ *
+ * With no directory roots and no CLI files, nothing is writable.
+ */
+export function isWritablePath(resolved: string): boolean {
+ if (cliLocalFiles.has(resolved)) return true;
+ // MCP file root โ always read-only, regardless of ancestry
+ if (allowedLocalFiles.has(resolved)) return false;
+ return [...allowedLocalDirs].some((dir) => {
+ if (!isAncestorDir(dir, resolved)) return false;
+ if (!writeFlags.allowUploadsRoot && path.basename(dir) === "uploads") {
+ return false;
+ }
+ return true;
+ });
+}
+
// Works both from source (server.ts) and compiled (dist/server.js)
const DIST_DIR = import.meta.filename.endsWith(".ts")
? path.join(import.meta.dirname, "dist")
: import.meta.dirname;
+// =============================================================================
+// Command Queue (shared across stateless server instances)
+// =============================================================================
+
+/** Commands expire after this many ms if never polled */
+const COMMAND_TTL_MS = 60_000; // 60 seconds
+
+/** Periodic sweep interval to drop stale queues */
+const SWEEP_INTERVAL_MS = 30_000; // 30 seconds
+
+/** Fixed batch window: when commands are present, wait this long before returning to let more accumulate */
+const POLL_BATCH_WAIT_MS = 200;
+const LONG_POLL_TIMEOUT_MS = 30_000; // Max time to hold a long-poll request open
+
+// =============================================================================
+// Interact Tool Input Schemas (runtime validators)
+// =============================================================================
+//
+// Annotation structure docs live in src/pdf-annotations.ts (the TS
+// interfaces) and in the interact tool description. The inputSchema
+// for `annotations` accepts z.record(z.any()) to keep the model-facing
+// API forgiving; adding strict validation here would be a behavior change.
+
+const FormField = z.object({
+ name: z.string(),
+ value: z.union([z.string(), z.boolean()]),
+});
+
+const PageInterval = z.object({
+ start: z.number().min(1).optional(),
+ end: z.number().min(1).optional(),
+});
+
+// =============================================================================
+// Command Queue โ wire protocol shared with the viewer
+// =============================================================================
+
+// PdfCommand is the single source of truth for what flows through the
+// poll queue. Defined once in src/commands.ts; both sides import it.
+// (`import type` โ no pdf-lib bundled into the server.)
+import type { PdfCommand } from "./src/commands.js";
+export type { PdfCommand };
+
+// =============================================================================
+// Pending get_pages Requests (request-response bridge via client)
+// =============================================================================
+
+// Keep well under the MCP SDK's DEFAULT_REQUEST_TIMEOUT_MSEC (60s) so we
+// reject first and return a real error instead of the client cancelling us.
+const GET_PAGES_TIMEOUT_MS = 45_000;
+
+/**
+ * Grace period for the viewer's first poll. If interact() arrives before the
+ * iframe has ever polled, we wait this long for it to show up (iframe mount +
+ * PDF load + startPolling). If no poll comes, the viewer almost certainly
+ * never rendered โ failing fast beats a silent 45s hang.
+ */
+const VIEWER_FIRST_POLL_GRACE_MS = 8_000;
+
+interface PageDataEntry {
+ page: number;
+ text?: string;
+ image?: string; // base64 PNG
+}
+
+const pendingPageRequests = new Map<
+ string,
+ (data: PageDataEntry[] | Error) => void
+>();
+
+/**
+ * Wait for the viewer to render and submit page data.
+ * Rejects on timeout or when the interact request is aborted upstream.
+ */
+function waitForPageData(
+ requestId: string,
+ signal?: AbortSignal,
+): Promise {
+ return new Promise((resolve, reject) => {
+ const settle = (v: PageDataEntry[] | Error) => {
+ clearTimeout(timer);
+ signal?.removeEventListener("abort", onAbort);
+ pendingPageRequests.delete(requestId);
+ v instanceof Error ? reject(v) : resolve(v);
+ };
+ const onAbort = () => settle(new Error("interact request cancelled"));
+ const timer = setTimeout(
+ () => settle(new Error("Timeout waiting for page data from viewer")),
+ GET_PAGES_TIMEOUT_MS,
+ );
+ signal?.addEventListener("abort", onAbort);
+ pendingPageRequests.set(requestId, settle);
+ });
+}
+
+/**
+ * Wait for the viewer's first poll_pdf_commands call.
+ *
+ * Called before waitForPageData() / waitForSaveData() so a viewer that never
+ * mounted fails in ~8s with a specific message instead of a generic 45s
+ * "Timeout waiting for ..." that gives no hint why.
+ *
+ * Intentionally does NOT touch pollWaiters: piggybacking on that single-slot
+ * Map races with poll_pdf_commands' batch-wait branch (which never cancels the
+ * prior waiter) and with concurrent interact calls (which would overwrite each
+ * other). A plain check loop on viewsPolled is stateless โ multiple callers
+ * can wait independently and all observe the same add() when it happens.
+ */
+async function ensureViewerIsPolling(uuid: string): Promise {
+ const deadline = Date.now() + VIEWER_FIRST_POLL_GRACE_MS;
+ while (!viewsPolled.has(uuid)) {
+ if (Date.now() >= deadline) {
+ throw new Error(
+ `Viewer never connected for viewUUID ${uuid} (no poll within ${VIEWER_FIRST_POLL_GRACE_MS / 1000}s). ` +
+ `The iframe likely failed to mount โ this happens when the conversation ` +
+ `goes idle before the viewer finishes loading. Call display_pdf again to get a fresh viewUUID.`,
+ );
+ }
+ await new Promise((r) => setTimeout(r, 100));
+ }
+}
+
+// =============================================================================
+// Pending save_as Requests (request-response bridge via client)
+// =============================================================================
+//
+// Same shape as get_pages: model's interact call blocks while the viewer
+// builds annotated bytes and posts them back. Reuses GET_PAGES_TIMEOUT_MS
+// (45s) โ generous because pdf-lib reflow on a large doc can take seconds.
+
+const pendingSaveRequests = new Map void>();
+
+/**
+ * Wait for the viewer to build annotated PDF bytes and submit them as base64.
+ * Rejects on timeout, abort, or when the viewer reports an error.
+ */
+function waitForSaveData(
+ requestId: string,
+ signal?: AbortSignal,
+): Promise {
+ return new Promise((resolve, reject) => {
+ const settle = (v: string | Error) => {
+ clearTimeout(timer);
+ signal?.removeEventListener("abort", onAbort);
+ pendingSaveRequests.delete(requestId);
+ v instanceof Error ? reject(v) : resolve(v);
+ };
+ const onAbort = () => settle(new Error("interact request cancelled"));
+ const timer = setTimeout(
+ () => settle(new Error("Timeout waiting for PDF bytes from viewer")),
+ GET_PAGES_TIMEOUT_MS,
+ );
+ signal?.addEventListener("abort", onAbort);
+ pendingSaveRequests.set(requestId, settle);
+ });
+}
+
+const pendingStateRequests = new Map void>();
+
+/**
+ * Wait for the viewer to report its current state (page, zoom, selection, โฆ)
+ * as a JSON string. Same timeout/abort semantics as waitForSaveData.
+ */
+function waitForViewerState(
+ requestId: string,
+ signal?: AbortSignal,
+): Promise {
+ return new Promise((resolve, reject) => {
+ const settle = (v: string | Error) => {
+ clearTimeout(timer);
+ signal?.removeEventListener("abort", onAbort);
+ pendingStateRequests.delete(requestId);
+ v instanceof Error ? reject(v) : resolve(v);
+ };
+ const onAbort = () => settle(new Error("interact request cancelled"));
+ const timer = setTimeout(
+ () => settle(new Error("Timeout waiting for viewer state")),
+ GET_PAGES_TIMEOUT_MS,
+ );
+ signal?.addEventListener("abort", onAbort);
+ pendingStateRequests.set(requestId, settle);
+ });
+}
+
+interface QueueEntry {
+ commands: PdfCommand[];
+ /** Timestamp of the most recent enqueue or dequeue */
+ lastActivity: number;
+}
+
+const commandQueues = new Map();
+
+/** Waiters for long-poll: resolve callback wakes up a blocked poll_pdf_commands */
+const pollWaiters = new Map void>();
+
+/**
+ * viewUUIDs that have been polled at least once. A view missing from this set
+ * means the iframe never reached startPolling() โ usually because it wasn't
+ * mounted yet, or ontoolresult threw before the poll loop started. Used to
+ * fail fast in get_screenshot/get_text instead of waiting the full 45s for
+ * a viewer that was never there.
+ */
+const viewsPolled = new Set();
+
+/**
+ * Resolved local file path per viewer UUID, for save_as without an explicit
+ * target. Only set for local files (remote PDFs have nothing to overwrite).
+ * Populated during display_pdf, cleared by the heartbeat sweep.
+ *
+ * Exported for tests.
+ */
+export const viewSourcePaths = new Map();
+
+/** Valid form field names per viewer UUID (populated during display_pdf) */
+const viewFieldNames = new Map>();
+
+/**
+ * Active fs.watch per view. Only created for local files when interact is
+ * enabled (stdio). Watcher is re-established on `rename` events to survive
+ * atomic writes (vim/vscode write-to-tmp-then-rename changes the inode).
+ */
+interface ViewFileWatch {
+ filePath: string;
+ watcher: fs.FSWatcher;
+ lastMtimeMs: number;
+ debounce: ReturnType | null;
+}
+const viewFileWatches = new Map();
+
+/**
+ * Per-view heartbeat. THIS is what the sweep iterates โ not commandQueues.
+ *
+ * Why not commandQueues: display_pdf populates viewFieldNames/
+ * viewFileWatches but never touches commandQueues (only enqueueCommand does,
+ * and it's triply gated). And dequeueCommands deletes the entry on every poll,
+ * so even when it exists the sweep's TTL window is ~200ms wide. Net effect:
+ * the sweep found nothing and the aux maps leaked every display_pdf call.
+ * viewFileWatches entries hold an fs.StatWatcher (FD + timer) โ slow FD
+ * exhaustion on HTTP --enable-interact.
+ */
+const viewLastActivity = new Map();
+
+/** Register or refresh the heartbeat for a view. */
+function touchView(uuid: string): void {
+ viewLastActivity.set(uuid, Date.now());
+}
+
+function pruneStaleQueues(): void {
+ const now = Date.now();
+ for (const [uuid, lastActivity] of viewLastActivity) {
+ if (now - lastActivity > COMMAND_TTL_MS) {
+ viewLastActivity.delete(uuid);
+ commandQueues.delete(uuid);
+ viewFieldNames.delete(uuid);
+ viewsPolled.delete(uuid);
+ viewSourcePaths.delete(uuid);
+ stopFileWatch(uuid);
+ }
+ }
+}
+
+// Periodic sweep so abandoned views don't leak
+setInterval(pruneStaleQueues, SWEEP_INTERVAL_MS).unref();
+
+function enqueueCommand(viewUUID: string, command: PdfCommand): void {
+ let entry = commandQueues.get(viewUUID);
+ if (!entry) {
+ entry = { commands: [], lastActivity: Date.now() };
+ commandQueues.set(viewUUID, entry);
+ }
+ entry.commands.push(command);
+ entry.lastActivity = Date.now();
+ touchView(viewUUID);
+
+ // Wake up any long-polling request waiting for this viewUUID
+ const waiter = pollWaiters.get(viewUUID);
+ if (waiter) {
+ pollWaiters.delete(viewUUID);
+ waiter();
+ }
+}
+
+function dequeueCommands(viewUUID: string): PdfCommand[] {
+ // Poll is activity โ keep the view alive even when the queue is empty
+ // (the common case: viewer polls every ~30s with nothing to receive).
+ touchView(viewUUID);
+ const entry = commandQueues.get(viewUUID);
+ if (!entry) return [];
+ const commands = entry.commands;
+ commandQueues.delete(viewUUID);
+ return commands;
+}
+
+// =============================================================================
+// File Watching (local files, stdio only)
+// =============================================================================
+
+const FILE_WATCH_DEBOUNCE_MS = 150;
+
+export function startFileWatch(viewUUID: string, filePath: string): void {
+ const resolved = path.resolve(filePath);
+ let stat: fs.Stats;
+ try {
+ stat = fs.statSync(resolved);
+ } catch {
+ return; // vanished between validation and here
+ }
+
+ // Replace any existing watcher for this view
+ stopFileWatch(viewUUID);
+
+ const entry: ViewFileWatch = {
+ filePath: resolved,
+ watcher: null as unknown as fs.FSWatcher,
+ lastMtimeMs: stat.mtimeMs,
+ debounce: null,
+ };
+
+ const onEvent = (eventType: string): void => {
+ if (entry.debounce) clearTimeout(entry.debounce);
+ entry.debounce = setTimeout(() => {
+ entry.debounce = null;
+ let s: fs.Stats;
+ try {
+ s = fs.statSync(resolved);
+ } catch {
+ return; // gone mid-atomic-write; next rename will re-attach
+ }
+ if (s.mtimeMs === entry.lastMtimeMs) return; // spurious / already sent
+ entry.lastMtimeMs = s.mtimeMs;
+ enqueueCommand(viewUUID, { type: "file_changed", mtimeMs: s.mtimeMs });
+ }, FILE_WATCH_DEBOUNCE_MS);
+
+ // Atomic saves replace the inode โ old watcher stops firing. Re-attach.
+ if (eventType === "rename") {
+ try {
+ entry.watcher.close();
+ } catch {
+ /* already closed */
+ }
+ try {
+ entry.watcher = fs.watch(resolved, onEvent);
+ } catch {
+ // File removed, not replaced. Leave closed; pruneStaleQueues cleans up.
+ }
+ }
+ };
+
+ try {
+ entry.watcher = fs.watch(resolved, onEvent);
+ } catch {
+ return; // fs.watch unsupported (e.g. some network filesystems)
+ }
+ viewFileWatches.set(viewUUID, entry);
+}
+
+export function stopFileWatch(viewUUID: string): void {
+ const entry = viewFileWatches.get(viewUUID);
+ if (!entry) return;
+ if (entry.debounce) clearTimeout(entry.debounce);
+ try {
+ entry.watcher.close();
+ } catch {
+ /* ignore */
+ }
+ viewFileWatches.delete(viewUUID);
+}
+
// =============================================================================
// URL Validation & Normalization
// =============================================================================
@@ -175,10 +638,19 @@ export function validateUrl(url: string): {
// Remote URL - require HTTPS
try {
const parsed = new URL(url);
- if (parsed.protocol !== "https:") {
- return { valid: false, error: `Only HTTPS URLs are allowed: ${url}` };
+ if (parsed.protocol === "https:") return { valid: true };
+ // Loopback HTTP is opt-in (test fixtures, local dev). Off by default so a
+ // remotely-deployed server can't be made to probe its own internal ports.
+ if (
+ process.env.PDF_SERVER_ALLOW_LOOPBACK_HTTP &&
+ parsed.protocol === "http:" &&
+ (parsed.hostname === "127.0.0.1" ||
+ parsed.hostname === "localhost" ||
+ parsed.hostname === "[::1]")
+ ) {
+ return { valid: true };
}
- return { valid: true };
+ return { valid: false, error: `Only HTTPS URLs are allowed: ${url}` };
} catch {
return { valid: false, error: `Invalid URL: ${url}` };
}
@@ -230,8 +702,11 @@ export interface PdfCache {
* - CACHE_INACTIVITY_TIMEOUT_MS of no access (resets on each access)
* - CACHE_MAX_LIFETIME_MS from creation (absolute timeout)
*/
-export function createPdfCache(): PdfCache {
+export function createPdfCache(
+ maxTotalBytes: number = CACHE_MAX_TOTAL_BYTES,
+): PdfCache {
const cache = new Map();
+ let totalBytes = 0;
/** Delete a cache entry and clear its timers */
function deleteCacheEntry(url: string): void {
@@ -239,6 +714,7 @@ export function createPdfCache(): PdfCache {
if (entry) {
clearTimeout(entry.inactivityTimer);
clearTimeout(entry.maxLifetimeTimer);
+ totalBytes -= entry.data.length;
cache.delete(url);
}
}
@@ -254,6 +730,10 @@ export function createPdfCache(): PdfCache {
deleteCacheEntry(url);
}, CACHE_INACTIVITY_TIMEOUT_MS);
+ // Move to end of insertion order so size-cap eviction is LRU.
+ cache.delete(url);
+ cache.set(url, entry);
+
return entry.data;
}
@@ -262,6 +742,12 @@ export function createPdfCache(): PdfCache {
// Clear any existing entry first
deleteCacheEntry(url);
+ // Evict least-recently-used entries until under the byte cap.
+ for (const oldest of cache.keys()) {
+ if (totalBytes + data.length <= maxTotalBytes) break;
+ deleteCacheEntry(oldest);
+ }
+
const entry: CacheEntry = {
data,
createdAt: Date.now(),
@@ -274,6 +760,7 @@ export function createPdfCache(): PdfCache {
};
cache.set(url, entry);
+ totalBytes += data.length;
}
/** Slice a cached or freshly-fetched full body to the requested range. */
@@ -398,6 +885,61 @@ export function createPdfCache(): PdfCache {
};
}
+/**
+ * pdf.js range transport backed by {@link PdfCache.readPdfRange}. Lets
+ * getDocument() fetch only the byte ranges it needs (xref, /AcroForm dict)
+ * instead of the whole file. With disableAutoFetch, a PDF without form
+ * fields is opened with ~5% of bytes fetched.
+ *
+ * pdf.js has no upstream error channel on PDFDataRangeTransport (its
+ * `abort()` is a no-op stub it calls *on* us, not the other way). Callers
+ * must `Promise.race` their pdf.js awaits against {@link failed}, which
+ * rejects on the first fetch error.
+ */
+export class PdfCacheRangeTransport extends PDFDataRangeTransport {
+ /** Rejects on the first range-fetch error; never resolves. */
+ readonly failed: Promise;
+ private fail!: (e: unknown) => void;
+
+ constructor(
+ private url: string,
+ length: number,
+ private readPdfRange: PdfCache["readPdfRange"],
+ ) {
+ super(length, null);
+ this.failed = new Promise((_, reject) => {
+ this.fail = reject;
+ });
+ // Don't crash the process if no one is racing yet.
+ this.failed.catch(() => {});
+ }
+
+ override requestDataRange(begin: number, end: number): void {
+ void this.deliver(begin, end).catch((e) => this.fail(e));
+ }
+
+ /**
+ * pdf.js coalesces adjacent missing chunks into one unbounded request, but
+ * readPdfRange clamps each call to MAX_CHUNK_BYTES. Its reader is keyed by
+ * the original `begin` and removed after one delivery, so we must accumulate
+ * slices and call onDataRange exactly once with the full buffer.
+ */
+ private async deliver(begin: number, end: number): Promise {
+ const buf = new Uint8Array(end - begin);
+ let off = 0;
+ while (off < buf.length) {
+ const want = Math.min(buf.length - off, MAX_CHUNK_BYTES);
+ const { data } = await this.readPdfRange(this.url, begin + off, want);
+ if (data.length === 0) {
+ throw new Error(`empty range at ${begin + off} for ${this.url}`);
+ }
+ buf.set(data.subarray(0, Math.min(data.length, buf.length - off)), off);
+ off += data.length;
+ }
+ this.onDataRange(begin, buf);
+ }
+}
+
// =============================================================================
// MCP Roots
// =============================================================================
@@ -439,50 +981,376 @@ async function refreshRoots(server: Server): Promise {
}
}
+// =============================================================================
+// PDF Form Field Extraction
+// =============================================================================
+
+/**
+ * Extract form fields from a PDF and build an elicitation schema.
+ * Returns null if the PDF has no form fields.
+ */
+/** Shape of field objects returned by pdfjs-dist's getFieldObjects(). */
+interface PdfJsFieldObject {
+ type: string;
+ name: string;
+ editable: boolean;
+ exportValues?: string;
+ items?: Array<{ exportValue: string; displayValue: string }>;
+}
+
+/** Detailed info about a form field, including its location on the page. */
+interface FormFieldInfo {
+ name: string;
+ type: string;
+ page: number;
+ label?: string;
+ /** Bounding box in model coordinates (top-left origin) */
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ /** Radio button export value (buttonValue) โ distinguishes widgets that share a field name. */
+ exportValue?: string;
+ /** Dropdown/listbox option values, as seen in the widget's `options` array. */
+ options?: string[];
+}
+
+/**
+ * Open `url` via {@link PdfCacheRangeTransport} and return form metadata.
+ * Uses `disableAutoFetch` so PDFs without an AcroForm are probed with only
+ * the trailer/xref/catalog (~5-25% of bytes); PDFs with forms still walk
+ * every page via {@link extractFormFieldInfo} but those are typically small.
+ * All errors (including range-fetch failures surfaced via
+ * {@link PdfCacheRangeTransport.failed}) resolve to empty results.
+ */
+async function probeFormFields(
+ url: string,
+ totalBytes: number,
+ readPdfRange: PdfCache["readPdfRange"],
+): Promise<{
+ formSchema: Awaited>;
+ fieldInfo: FormFieldInfo[];
+}> {
+ // Assigned sequentially below so a throw in extractFormFieldInfo (no per-page
+ // guard, unlike extractFormSchema) doesn't discard an already-computed schema.
+ let formSchema: Awaited> = null;
+ let fieldInfo: FormFieldInfo[] = [];
+ try {
+ const transport = new PdfCacheRangeTransport(url, totalBytes, readPdfRange);
+ const orFail = (p: Promise): Promise =>
+ Promise.race([p, transport.failed]);
+ const pdfDoc = await orFail(
+ getDocument({
+ range: transport,
+ length: totalBytes,
+ disableAutoFetch: true,
+ disableStream: true,
+ rangeChunkSize: 64 * 1024,
+ standardFontDataUrl: STANDARD_FONT_DATA_URL,
+ StandardFontDataFactory: FetchStandardFontDataFactory,
+ verbosity: VerbosityLevel.ERRORS,
+ }).promise,
+ );
+ try {
+ const fieldObjects = (await orFail(pdfDoc.getFieldObjects())) as Record<
+ string,
+ PdfJsFieldObject[]
+ > | null;
+ if (fieldObjects && Object.keys(fieldObjects).length > 0) {
+ formSchema = await orFail(extractFormSchema(pdfDoc, fieldObjects));
+ fieldInfo = await orFail(extractFormFieldInfo(pdfDoc));
+ }
+ } finally {
+ pdfDoc.destroy();
+ }
+ } catch {
+ // Non-fatal โ return whatever was assigned before the throw.
+ }
+ return { formSchema, fieldInfo };
+}
+
+/**
+ * Extract detailed form field info (name, type, page, bounding box, label)
+ * from a PDF. Bounding boxes are converted to model coordinates (top-left origin).
+ */
+async function extractFormFieldInfo(
+ pdfDoc: PDFDocumentProxy,
+): Promise {
+ const fields: FormFieldInfo[] = [];
+ for (let i = 1; i <= pdfDoc.numPages; i++) {
+ const page = await pdfDoc.getPage(i);
+ const pageHeight = page.getViewport({ scale: 1.0 }).height;
+ const annotations = await page.getAnnotations();
+ for (const ann of annotations) {
+ // Only include form widgets (annotationType 20)
+ if (ann.annotationType !== 20) continue;
+ if (!ann.rect) continue;
+
+ const fieldName = ann.fieldName || "";
+ const fieldType = ann.fieldType || "unknown";
+
+ // PDF rect is [x1, y1, x2, y2] in bottom-left origin
+ const x1 = Math.min(ann.rect[0], ann.rect[2]);
+ const y1 = Math.min(ann.rect[1], ann.rect[3]);
+ const x2 = Math.max(ann.rect[0], ann.rect[2]);
+ const y2 = Math.max(ann.rect[1], ann.rect[3]);
+ const width = x2 - x1;
+ const height = y2 - y1;
+
+ // Convert to model coords (top-left origin): modelY = pageHeight - pdfY - height
+ const modelY = pageHeight - y2;
+
+ // Choice widgets (combo/listbox) carry `options` as
+ // [{exportValue, displayValue}]. Expose export values โ that's
+ // what fill_form needs.
+ let options: string[] | undefined;
+ if (Array.isArray(ann.options) && ann.options.length > 0) {
+ options = ann.options
+ .map((o: { exportValue?: string }) => o?.exportValue)
+ .filter((v: unknown): v is string => typeof v === "string");
+ }
+
+ fields.push({
+ name: fieldName,
+ type: fieldType,
+ page: i,
+ x: Math.round(x1),
+ y: Math.round(modelY),
+ width: Math.round(width),
+ height: Math.round(height),
+ ...(ann.alternativeText ? { label: ann.alternativeText } : undefined),
+ // Radio: buttonValue is the per-widget export value โ the only
+ // thing distinguishing three `size [Btn]` lines from each other.
+ ...(ann.radioButton && ann.buttonValue != null
+ ? { exportValue: String(ann.buttonValue) }
+ : undefined),
+ ...(options?.length ? { options } : undefined),
+ });
+ }
+ }
+
+ return fields;
+}
+
+export async function extractFormSchema(
+ pdfDoc: PDFDocumentProxy,
+ fieldObjects: Record | null,
+): Promise<{
+ type: "object";
+ properties: Record;
+ required?: string[];
+} | null> {
+ if (!fieldObjects || Object.keys(fieldObjects).length === 0) {
+ return null;
+ }
+
+ const properties: Record = {};
+ for (const [name, fields] of Object.entries(fieldObjects)) {
+ // pdfjs returns the full field-tree array: for separated structures
+ // (pdf-lib) the typed widget is at [1+] behind a container at [0]; for
+ // merged/leaf entries (W-9, most authoring tools) it's at [0]. Pick the
+ // first entry that actually has a field type.
+ const field = fields.find((f) => f.type) ?? fields[0];
+ if (!field.editable) continue;
+
+ switch (field.type) {
+ case "text":
+ properties[name] = { type: "string", title: name };
+ break;
+ case "checkbox":
+ properties[name] = { type: "boolean", title: name };
+ break;
+ case "radiobutton": {
+ const options = fields
+ .map((f) => f.exportValues)
+ .filter((v): v is string => !!v && v !== "Off");
+ properties[name] =
+ options.length > 0
+ ? { type: "string", title: name, enum: options }
+ : { type: "string", title: name };
+ break;
+ }
+ case "combobox":
+ case "listbox": {
+ const items = field.items?.map((i) => i.exportValue).filter(Boolean);
+ properties[name] =
+ items && items.length > 0
+ ? { type: "string", title: name, enum: items }
+ : { type: "string", title: name };
+ break;
+ }
+ // Skip "button" (push buttons) and unknown types
+ }
+ }
+
+ // Collect alternativeText labels from per-page annotations
+ // (getFieldObjects doesn't include them)
+ const fieldLabels = new Map();
+ try {
+ for (let i = 1; i <= pdfDoc.numPages; i++) {
+ const page = await pdfDoc.getPage(i);
+ const annotations = await page.getAnnotations();
+ for (const ann of annotations) {
+ if (ann.fieldName && ann.alternativeText) {
+ fieldLabels.set(ann.fieldName, ann.alternativeText);
+ }
+ }
+ }
+ } catch {
+ // ignore
+ }
+
+ // Use labels as titles where available
+ for (const [name, prop] of Object.entries(properties)) {
+ const label = fieldLabels.get(name);
+ if (label) {
+ prop.title = label;
+ }
+ }
+
+ // If any editable field has a mechanical name (no human-readable label),
+ // elicitation would be confusing โ return null to skip it.
+ const hasMechanicalNames = Object.keys(properties).some((name) => {
+ if (fieldLabels.has(name)) return false;
+ return /[[\]().]/.test(name) || /^[A-Z0-9_]+$/.test(name);
+ });
+
+ if (Object.keys(properties).length === 0) return null;
+ if (hasMechanicalNames) return null;
+
+ return { type: "object", properties };
+}
+
// =============================================================================
// MCP Server Factory
// =============================================================================
-export function createServer(): McpServer {
+export interface CreateServerOptions {
+ /**
+ * Enable the `interact` tool and related command-queue infrastructure
+ * (in-memory command queue, `poll_pdf_commands`, `submit_page_data`).
+ * Only suitable for single-instance deployments (e.g. stdio transport).
+ * Defaults to false โ server exposes only `list_pdfs` and `display_pdf` (read-only).
+ */
+ enableInteract?: boolean;
+
+ /**
+ * Whether to honour MCP roots sent by the client.
+ *
+ * When a server is exposed over HTTP, the connecting client is
+ * typically remote and may advertise `roots` that refer to
+ * directories on the **client's** file system. Because the server
+ * resolves those paths locally, accepting them by default would give
+ * the remote client access to arbitrary directories on the
+ * **server's** machine.
+ *
+ * For stdio the client is typically local (e.g. Claude Desktop on the
+ * same machine), so roots are safe and enabled by default.
+ *
+ * Set this to `true` for HTTP only when you trust the client, or
+ * pass the `--use-client-roots` CLI flag.
+ *
+ * @default false
+ */
+ useClientRoots?: boolean;
+
+ /**
+ * Emit debug metadata to the viewer (currently: allowed roots shown
+ * in a floating bubble). Toggled by the `--debug` CLI flag.
+ */
+ debug?: boolean;
+}
+
+// Module-level singletons so they survive across createServer() calls โ in
+// stateless HTTP deployments a fresh server is created per request, and
+// per-instance caches are discarded immediately.
+const sharedPdfCache = createPdfCache();
+let cachedAppHtml: string | undefined;
+
+export function createServer(options: CreateServerOptions = {}): McpServer {
+ const { enableInteract = false, useClientRoots = false } = options;
+ const debug = options.debug ?? false;
+ const disableInteract = !enableInteract;
const server = new McpServer({ name: "PDF Server", version: "2.0.0" });
- // Fetch roots on initialization and subscribe to changes
- server.server.oninitialized = () => {
- refreshRoots(server.server);
- };
- server.server.setNotificationHandler(
- RootsListChangedNotificationSchema,
- async () => {
- await refreshRoots(server.server);
- },
- );
+ if (useClientRoots) {
+ // Fetch roots on initialization and subscribe to changes
+ server.server.oninitialized = () => {
+ refreshRoots(server.server);
+ };
+ server.server.setNotificationHandler(
+ "notifications/roots/list_changed",
+ async () => {
+ await refreshRoots(server.server);
+ },
+ );
+ }
- // Create session-local cache (isolated per server instance)
- const { readPdfRange } = createPdfCache();
+ const { readPdfRange } = sharedPdfCache;
// Tool: list_pdfs - List available PDFs
- server.tool(
+ server.registerTool(
"list_pdfs",
- "List available PDFs that can be displayed",
- {},
+ {
+ description: "List available PDFs that can be displayed",
+ inputSchema: z.object({}),
+ },
async (): Promise => {
- const pdfs: Array<{ url: string; type: "local" | "remote" }> = [];
+ const seen = new Set();
+ const localFiles: string[] = [];
+ const addLocal = (filePath: string) => {
+ const url = pathToFileUrl(filePath);
+ if (seen.has(url)) return;
+ seen.add(url);
+ localFiles.push(url);
+ };
- // Add local files
- for (const filePath of allowedLocalFiles) {
- pdfs.push({ url: pathToFileUrl(filePath), type: "local" });
- }
+ // Explicitly registered files (CLI args + file roots)
+ for (const filePath of allowedLocalFiles) addLocal(filePath);
+
+ // Walk directory roots for *.pdf files
+ const WALK_MAX_DEPTH = 8;
+ const WALK_MAX_FILES = 500;
+ let truncated = false;
+ const walk = async (dir: string, depth: number): Promise => {
+ if (depth > WALK_MAX_DEPTH || localFiles.length >= WALK_MAX_FILES) {
+ truncated ||= localFiles.length >= WALK_MAX_FILES;
+ return;
+ }
+ let entries;
+ try {
+ entries = await fs.promises.readdir(dir, { withFileTypes: true });
+ } catch {
+ return; // unreadable โ skip silently
+ }
+ for (const e of entries) {
+ if (localFiles.length >= WALK_MAX_FILES) {
+ truncated = true;
+ return;
+ }
+ // Skip dotfiles/dirs and common noise
+ if (e.name.startsWith(".") || e.name === "node_modules") continue;
+ const full = path.join(dir, e.name);
+ if (e.isDirectory()) {
+ await walk(full, depth + 1);
+ } else if (e.isFile() && /\.pdf$/i.test(e.name)) {
+ addLocal(full);
+ }
+ }
+ };
+ for (const dir of allowedLocalDirs) await walk(dir, 0);
// Build text
const parts: string[] = [];
- if (pdfs.length > 0) {
- parts.push(
- `Available PDFs:\n${pdfs.map((p) => `- ${p.url} (${p.type})`).join("\n")}`,
- );
+ if (localFiles.length > 0) {
+ const header = truncated
+ ? `Available PDFs (showing first ${WALK_MAX_FILES}):`
+ : `Available PDFs:`;
+ parts.push(`${header}\n${localFiles.map((u) => `- ${u}`).join("\n")}`);
}
if (allowedLocalDirs.size > 0) {
parts.push(
- `Allowed local directories (from client roots):\n${[...allowedLocalDirs].map((d) => `- ${d}`).join("\n")}\nAny PDF file under these directories can be displayed.`,
+ `Allowed local directories:\n${[...allowedLocalDirs].map((d) => `- ${d}`).join("\n")}\nAny PDF file under these directories can be displayed.`,
);
}
parts.push(
@@ -492,8 +1360,9 @@ export function createServer(): McpServer {
return {
content: [{ type: "text", text: parts.join("\n\n") }],
structuredContent: {
- localFiles: pdfs.filter((p) => p.type === "local").map((p) => p.url),
+ localFiles,
allowedDirectories: [...allowedLocalDirs],
+ truncated,
},
};
},
@@ -505,8 +1374,9 @@ export function createServer(): McpServer {
"read_pdf_bytes",
{
title: "Read PDF Bytes",
- description: "Read a range of bytes from a PDF (max 512KB per request)",
- inputSchema: {
+ description:
+ "Read a range of bytes from a PDF (max 512KB per request). The model should NOT call this tool directly.",
+ inputSchema: z.object({
url: z.string().describe("PDF URL or local file path"),
offset: z.number().min(0).default(0).describe("Byte offset"),
byteCount: z
@@ -515,7 +1385,7 @@ export function createServer(): McpServer {
.max(MAX_CHUNK_BYTES)
.default(MAX_CHUNK_BYTES)
.describe("Bytes to read"),
- },
+ }),
outputSchema: z.object({
url: z.string(),
bytes: z.string().describe("Base64 encoded bytes"),
@@ -583,27 +1453,85 @@ export function createServer(): McpServer {
"display_pdf",
{
title: "Display PDF",
- description: `Display an interactive PDF viewer.
+ description: disableInteract
+ ? `Show and render a PDF in a read-only viewer.
-Accepts:
-- Local files explicitly added to the server (use list_pdfs to see available files)
-- Local files under directories provided by the client as MCP roots
-- Any remote PDF accessible via HTTPS`,
- inputSchema: {
+Use this tool when the user wants to view or read a PDF. The renderer displays the document for viewing. The widget exposes app-registered tools for page navigation, text extraction, searching, and zoom control.
+
+Accepts local files (use list_pdfs), client MCP root directories, or any HTTPS URL.`
+ : `Open a PDF in an interactive viewer. Call this ONCE per PDF.
+
+**All follow-up actions go through the \`interact\` tool** with the returned viewUUID โ annotating, signing, stamping, filling forms, navigating, searching, extracting text/screenshots. Calling display_pdf again creates a SEPARATE viewer with a different viewUUID โ interact calls using the new UUID will not reach the viewer the user already sees.
+
+Returns a viewUUID in structuredContent. Pass it to \`interact\`:
+- add_annotations, update_annotations, remove_annotations, highlight_text
+- fill_form (fill PDF form fields)
+- navigate, search, find, search_navigate, zoom
+- get_text, get_screenshot, get_viewer_state (extract content / read selection & current page)
+- save_as (write annotated PDF to disk)
+
+Accepts local files (use list_pdfs), client MCP root directories, or any HTTPS URL.
+Set \`elicit_form_inputs\` to true to prompt the user to fill form fields before display.`,
+ inputSchema: z.object({
url: z
.string()
.default(DEFAULT_PDF)
.describe("PDF URL or local file path"),
page: z.number().min(1).default(1).describe("Initial page"),
- },
+ ...(disableInteract
+ ? {}
+ : {
+ elicit_form_inputs: z
+ .boolean()
+ .default(false)
+ .describe(
+ "If true and the PDF has form fields, prompt the user to fill them before displaying",
+ ),
+ }),
+ }),
outputSchema: z.object({
+ viewUUID: z
+ .string()
+ .describe(
+ "UUID for this viewer instance" +
+ (disableInteract ? "" : " โ pass to interact tool"),
+ ),
url: z.string(),
initialPage: z.number(),
totalBytes: z.number(),
+ formFieldValues: z
+ .record(z.string(), z.union([z.string(), z.boolean()]))
+ .optional()
+ .describe("Form field values filled by the user via elicitation"),
+ formFields: z
+ .array(
+ z.object({
+ name: z.string(),
+ type: z.string(),
+ page: z.number(),
+ label: z.string().optional(),
+ x: z.number(),
+ y: z.number(),
+ width: z.number(),
+ height: z.number(),
+ exportValue: z
+ .string()
+ .optional()
+ .describe("Radio button value โ pass this to fill_form"),
+ options: z
+ .array(z.string())
+ .optional()
+ .describe("Dropdown/listbox option values"),
+ }),
+ )
+ .optional()
+ .describe(
+ "Form fields with bounding boxes in model coordinates (top-left origin)",
+ ),
}),
_meta: { ui: { resourceUri: RESOURCE_URI } },
},
- async ({ url, page }): Promise => {
+ async ({ url, page, elicit_form_inputs }): Promise => {
const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url;
const validation = validateUrl(normalized);
@@ -616,21 +1544,1364 @@ Accepts:
// Probe file size so the client can set up range transport without an extra fetch
const { totalBytes } = await readPdfRange(normalized, 0, 1);
+ const uuid = randomUUID();
+ // Start the heartbeat now so the sweep can clean up viewFieldNames/
+ // viewFileWatches even if no interact calls ever happen.
+ if (!disableInteract) touchView(uuid);
+
+ // Check writability (governs save button; see isWritablePath doc).
+ // Also requires OS-level W_OK so we don't lie on read-only mounts.
+ let writable = false;
+ let debugResolved: string | undefined; // only used when --debug
+ if (isFileUrl(normalized) || isLocalPath(normalized)) {
+ const localPath = isFileUrl(normalized)
+ ? fileUrlToPath(normalized)
+ : decodeURIComponent(normalized);
+ const resolved = path.resolve(localPath);
+ debugResolved = resolved;
+ if (!disableInteract) viewSourcePaths.set(uuid, resolved);
+ if (isWritablePath(resolved)) {
+ try {
+ await fs.promises.access(resolved, fs.constants.W_OK);
+ writable = true;
+ } catch {
+ // Not writable โ leave false
+ }
+ }
+ // Watch for external changes (stdio only โ needs the poll channel)
+ if (!disableInteract) {
+ startFileWatch(uuid, localPath);
+ }
+ }
+
+ const { formSchema, fieldInfo } = await probeFormFields(
+ normalized,
+ totalBytes,
+ readPdfRange,
+ );
+ if (formSchema) {
+ viewFieldNames.set(uuid, new Set(Object.keys(formSchema.properties)));
+ }
+ if (fieldInfo.length > 0 && !viewFieldNames.has(uuid)) {
+ viewFieldNames.set(
+ uuid,
+ new Set(fieldInfo.map((f) => f.name).filter(Boolean)),
+ );
+ }
+
+ // Elicit form field values if requested and client supports it
+ let formFieldValues: Record | undefined;
+ let elicitResult: ElicitResult | undefined;
+ if (elicit_form_inputs && formSchema) {
+ const clientCaps = server.server.getClientCapabilities();
+ if (clientCaps?.elicitation?.form) {
+ try {
+ elicitResult = await server.server.elicitInput({
+ message: `Please fill in the PDF form fields for "${normalized.split("/").pop() || normalized}":`,
+ requestedSchema: formSchema,
+ });
+ if (elicitResult.action === "accept" && elicitResult.content) {
+ formFieldValues = {};
+ for (const [k, v] of Object.entries(elicitResult.content)) {
+ if (typeof v === "string" || typeof v === "boolean") {
+ formFieldValues[k] = v;
+ }
+ }
+ // Queue fill_form command so the viewer picks it up
+ enqueueCommand(uuid, {
+ type: "fill_form",
+ fields: Object.entries(formFieldValues).map(
+ ([name, value]) => ({ name, value }),
+ ),
+ });
+ }
+ } catch (err) {
+ // Elicitation failed โ continue without form values
+ console.error("[pdf-server] Form elicitation failed:", err);
+ }
+ }
+ }
+
+ const contentParts: Array<{ type: "text"; text: string }> = [
+ {
+ type: "text",
+ text: disableInteract
+ ? `Displaying PDF: ${normalized}`
+ : `PDF opened. viewUUID: ${uuid}
+
+โ To annotate, sign, stamp, fill forms, navigate, extract, or save to a file: call \`interact\` with this viewUUID.
+โ DO NOT call display_pdf again โ that spawns a separate viewer with a different viewUUID; your interact calls would target the new empty one, not the one the user is looking at.
+
+URL: ${normalized}`,
+ },
+ ];
+
+ if (formFieldValues && Object.keys(formFieldValues).length > 0) {
+ const fieldSummary = Object.entries(formFieldValues)
+ .map(
+ ([name, value]) =>
+ ` ${name}: ${typeof value === "boolean" ? (value ? "checked" : "unchecked") : value}`,
+ )
+ .join("\n");
+ contentParts.push({
+ type: "text",
+ text: `\nUser-provided form field values:\n${fieldSummary}`,
+ });
+ } else if (
+ elicit_form_inputs &&
+ elicitResult &&
+ elicitResult.action !== "accept"
+ ) {
+ contentParts.push({
+ type: "text",
+ text: `\nForm elicitation was ${elicitResult.action}d by the user.`,
+ });
+ }
+
+ // Include detailed form field info so the model can locate and fill fields
+ if (fieldInfo.length > 0) {
+ // Group by page
+ const byPage = new Map();
+ for (const f of fieldInfo) {
+ let list = byPage.get(f.page);
+ if (!list) {
+ list = [];
+ byPage.set(f.page, list);
+ }
+ list.push(f);
+ }
+ const lines: string[] = [
+ `\nForm fields (${fieldInfo.length})${disableInteract ? "" : " โ use fill_form with {name, value}"}:`,
+ ];
+ for (const [pg, fields] of [...byPage.entries()].sort(
+ (a, b) => a[0] - b[0],
+ )) {
+ lines.push(` Page ${pg}:`);
+ for (const f of fields) {
+ const label = f.label ? ` "${f.label}"` : "";
+ const nameStr = f.name || "(unnamed)";
+ // Radio: = tells the model what value to pass.
+ // Dropdown: options:[...] lists valid choices.
+ const exportSuffix = f.exportValue ? `=${f.exportValue}` : "";
+ const optsSuffix = f.options
+ ? ` options:[${f.options.join(", ")}]`
+ : "";
+ lines.push(
+ ` ${nameStr}${exportSuffix}${label} [${f.type}] at (${f.x},${f.y}) ${f.width}ร${f.height}${optsSuffix}`,
+ );
+ }
+ }
+ contentParts.push({ type: "text", text: lines.join("\n") });
+ } else {
+ // Fallback to simple field name listing if detailed info unavailable
+ const fieldNames = viewFieldNames.get(uuid);
+ if (fieldNames && fieldNames.size > 0) {
+ contentParts.push({
+ type: "text",
+ text: `\nForm fields${disableInteract ? "" : " available for fill_form"}: ${[...fieldNames].join(", ")}`,
+ });
+ }
+ }
return {
- content: [{ type: "text", text: `Displaying PDF: ${normalized}` }],
+ content: contentParts,
structuredContent: {
+ viewUUID: uuid,
url: normalized,
initialPage: page,
totalBytes,
+ ...(formFieldValues ? { formFieldValues } : {}),
+ ...(fieldInfo.length > 0 ? { formFields: fieldInfo } : {}),
},
_meta: {
- viewUUID: randomUUID(),
+ viewUUID: uuid,
+ interactEnabled: !disableInteract,
+ writable,
+ // Debug: viewer renders this in a floating bubble (--debug flag).
+ ...(debug
+ ? {
+ _debug: {
+ resolved: debugResolved,
+ writable,
+ isWritablePath: debugResolved
+ ? isWritablePath(debugResolved)
+ : undefined,
+ cliLocalFiles: [...cliLocalFiles],
+ allowedLocalFiles: [...allowedLocalFiles],
+ allowedLocalDirs: [...allowedLocalDirs],
+ },
+ }
+ : {}),
},
};
},
);
+ if (!disableInteract) {
+ // Schema for a single interact command (used in commands array)
+ const InteractCommandSchema = z.object({
+ action: z
+ .enum([
+ "navigate",
+ "search",
+ "find",
+ "search_navigate",
+ "zoom",
+ "add_annotations",
+ "update_annotations",
+ "remove_annotations",
+ "highlight_text",
+ "fill_form",
+ "get_text",
+ "get_screenshot",
+ "get_viewer_state",
+ "save_as",
+ ])
+ .describe("Action to perform"),
+ page: z
+ .number()
+ .min(1)
+ .optional()
+ .describe(
+ "Page number (for navigate, highlight_text, get_screenshot, get_text)",
+ ),
+ query: z
+ .string()
+ .optional()
+ .describe("Search text (for search / find / highlight_text)"),
+ matchIndex: z
+ .number()
+ .min(0)
+ .optional()
+ .describe("Match index (for search_navigate)"),
+ scale: z
+ .number()
+ .min(0.5)
+ .max(3.0)
+ .optional()
+ .describe("Zoom scale, 1.0 = 100% (for zoom)"),
+ annotations: z
+ .array(z.record(z.string(), z.any()))
+ .optional()
+ .describe(
+ "Annotation objects (see types in description). Each needs: id, type, page. For update_annotations only id+type are required.",
+ ),
+ ids: z
+ .array(z.string())
+ .optional()
+ .describe("Annotation IDs (for remove_annotations)"),
+ color: z
+ .string()
+ .optional()
+ .describe("Color override (for highlight_text)"),
+ content: z
+ .string()
+ .optional()
+ .describe("Tooltip/note content (for highlight_text)"),
+ fields: z
+ .array(FormField)
+ .optional()
+ .describe(
+ "Form fields to fill (for fill_form): { name, value } where value is string or boolean",
+ ),
+ intervals: z
+ .array(PageInterval)
+ .optional()
+ .describe(
+ "Page ranges for get_text. Each has optional start/end. [{start:1,end:5}], [{}] = all pages. Max 20 pages.",
+ ),
+ path: z
+ .string()
+ .optional()
+ .describe(
+ "Target file path for save_as. Absolute path or file:// URL. Omit to overwrite the original file (requires overwrite: true).",
+ ),
+ overwrite: z
+ .boolean()
+ .optional()
+ .describe("Overwrite if file exists (for save_as). Default false."),
+ });
+
+ type InteractCommand = z.infer;
+ type ContentPart =
+ | { type: "text"; text: string }
+ | { type: "image"; data: string; mimeType: string };
+
+ /**
+ * Resolve an image annotation: fetch imageUrl โ imageData if needed,
+ * auto-detect dimensions, and set defaults for x/y.
+ *
+ * SECURITY: imageUrl is model-controlled. It must pass the same
+ * validateUrl() gate as display_pdf/save_pdf โ otherwise the model
+ * can request `{imageUrl:"/Users/x/.ssh/id_rsa"}`, we'd readFile it,
+ * base64 the bytes, ship them to the iframe, and get_screenshot (or
+ * any future echo path) reads them back. Throws on rejection so the
+ * tool result carries the error; silent skip hides the attack attempt.
+ */
+ async function resolveImageAnnotation(
+ ann: Record,
+ ): Promise {
+ // Fetch image data from URL if no imageData provided
+ if (!ann.imageData && ann.imageUrl) {
+ const url = String(ann.imageUrl);
+ // Same gate as every other local/remote read in this server.
+ // Local: must be in allowedLocalFiles or under allowedLocalDirs.
+ // Remote: must be https://.
+ const check = validateUrl(url);
+ if (!check.valid) {
+ throw new Error(
+ `imageUrl rejected by validateUrl: ${check.error ?? url}`,
+ );
+ }
+ let imgBytes: Uint8Array;
+ if (url.startsWith("https://")) {
+ const resp = await fetch(url);
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} for ${url}`);
+ imgBytes = new Uint8Array(await resp.arrayBuffer());
+ } else {
+ // validateUrl already confirmed this path is under an allowed root.
+ const filePath = isFileUrl(url)
+ ? fileUrlToPath(url)
+ : decodeURIComponent(url);
+ imgBytes = await fs.promises.readFile(path.resolve(filePath));
+ }
+ ann.imageData = Buffer.from(imgBytes).toString("base64");
+ }
+
+ // Auto-detect mimeType from magic bytes if not set
+ if (ann.imageData && !ann.mimeType) {
+ const bytes = Buffer.from(ann.imageData, "base64");
+ if (
+ bytes[0] === 0x89 &&
+ bytes[1] === 0x50 &&
+ bytes[2] === 0x4e &&
+ bytes[3] === 0x47
+ ) {
+ ann.mimeType = "image/png";
+ } else {
+ ann.mimeType = "image/jpeg";
+ }
+ }
+
+ // Auto-detect dimensions from image if not specified
+ if (ann.imageData && (ann.width == null || ann.height == null)) {
+ const dims = detectImageDimensions(
+ Buffer.from(ann.imageData, "base64"),
+ );
+ if (dims) {
+ const maxWidth = 200; // default max width in PDF points
+ const aspectRatio = dims.height / dims.width;
+ ann.width = ann.width ?? Math.min(dims.width, maxWidth);
+ ann.height = ann.height ?? ann.width * aspectRatio;
+ } else {
+ ann.width = ann.width ?? 200;
+ ann.height = ann.height ?? 200;
+ }
+ }
+
+ // Default position if not specified
+ ann.x = ann.x ?? 72;
+ ann.y = ann.y ?? 72;
+ }
+
+ /**
+ * Detect image dimensions from PNG or JPEG bytes.
+ */
+ function detectImageDimensions(
+ bytes: Buffer,
+ ): { width: number; height: number } | null {
+ // PNG: width at offset 16 (4 bytes BE), height at offset 20 (4 bytes BE)
+ if (
+ bytes[0] === 0x89 &&
+ bytes[1] === 0x50 &&
+ bytes[2] === 0x4e &&
+ bytes[3] === 0x47
+ ) {
+ if (bytes.length >= 24) {
+ const width = bytes.readUInt32BE(16);
+ const height = bytes.readUInt32BE(20);
+ return { width, height };
+ }
+ }
+ // JPEG: scan for SOF0/SOF2 markers (0xFF 0xC0 / 0xFF 0xC2)
+ if (bytes[0] === 0xff && bytes[1] === 0xd8) {
+ let offset = 2;
+ while (offset < bytes.length - 8) {
+ if (bytes[offset] !== 0xff) break;
+ const marker = bytes[offset + 1];
+ if (marker === 0xc0 || marker === 0xc2) {
+ const height = bytes.readUInt16BE(offset + 5);
+ const width = bytes.readUInt16BE(offset + 7);
+ return { width, height };
+ }
+ const segLen = bytes.readUInt16BE(offset + 2);
+ offset += 2 + segLen;
+ }
+ }
+ return null;
+ }
+
+ /** Process a single interact command. Returns content parts and an isError flag. */
+ async function processInteractCommand(
+ uuid: string,
+ cmd: InteractCommand,
+ signal?: AbortSignal,
+ ): Promise<{ content: ContentPart[]; isError?: boolean }> {
+ const {
+ action,
+ page,
+ query,
+ matchIndex,
+ scale,
+ annotations,
+ ids,
+ color,
+ content,
+ fields,
+ intervals,
+ path: savePath,
+ overwrite,
+ } = cmd;
+
+ let description: string;
+ switch (action) {
+ case "navigate":
+ if (page == null)
+ return {
+ content: [{ type: "text", text: "navigate requires `page`" }],
+ isError: true,
+ };
+ enqueueCommand(uuid, { type: "navigate", page });
+ description = `navigate to page ${page}`;
+ break;
+ case "search":
+ if (!query)
+ return {
+ content: [{ type: "text", text: "search requires `query`" }],
+ isError: true,
+ };
+ enqueueCommand(uuid, { type: "search", query });
+ description = `search for "${query}"`;
+ break;
+ case "find":
+ if (!query)
+ return {
+ content: [{ type: "text", text: "find requires `query`" }],
+ isError: true,
+ };
+ enqueueCommand(uuid, { type: "find", query });
+ description = `find "${query}" (silent)`;
+ break;
+ case "search_navigate":
+ if (matchIndex == null)
+ return {
+ content: [
+ {
+ type: "text",
+ text: "search_navigate requires `matchIndex`",
+ },
+ ],
+ isError: true,
+ };
+ enqueueCommand(uuid, { type: "search_navigate", matchIndex });
+ description = `go to match #${matchIndex}`;
+ break;
+ case "zoom":
+ if (scale == null)
+ return {
+ content: [{ type: "text", text: "zoom requires `scale`" }],
+ isError: true,
+ };
+ enqueueCommand(uuid, { type: "zoom", scale });
+ description = `zoom to ${Math.round(scale * 100)}%`;
+ break;
+ case "add_annotations":
+ if (!annotations || annotations.length === 0)
+ return {
+ content: [
+ {
+ type: "text",
+ text: "add_annotations requires `annotations` array",
+ },
+ ],
+ isError: true,
+ };
+ // Resolve image annotations: fetch imageUrl โ imageData, auto-detect dimensions.
+ // Rejection (path not allowed, not https, fetch failed) surfaces as
+ // a tool error so the model sees it โ don't silently skip.
+ try {
+ for (const ann of annotations) {
+ if ((ann as any).type === "image") {
+ await resolveImageAnnotation(ann as any);
+ }
+ }
+ } catch (err) {
+ return {
+ content: [
+ {
+ type: "text",
+ text: `add_annotations: ${err instanceof Error ? err.message : String(err)}`,
+ },
+ ],
+ isError: true,
+ };
+ }
+ enqueueCommand(uuid, {
+ type: "add_annotations",
+ // resolveImageAnnotation populates optional x/y/width/height;
+ // input is validated as Record[] so this cast is
+ // the wire-protocol promise, not a compiler guarantee.
+ annotations: annotations as Extract<
+ PdfCommand,
+ { type: "add_annotations" }
+ >["annotations"],
+ });
+ description = `add ${annotations.length} annotation(s)`;
+ break;
+ case "update_annotations":
+ if (!annotations || annotations.length === 0)
+ return {
+ content: [
+ {
+ type: "text",
+ text: "update_annotations requires `annotations` array",
+ },
+ ],
+ isError: true,
+ };
+ enqueueCommand(uuid, {
+ type: "update_annotations",
+ annotations: annotations as Extract<
+ PdfCommand,
+ { type: "update_annotations" }
+ >["annotations"],
+ });
+ description = `update ${annotations.length} annotation(s)`;
+ break;
+ case "remove_annotations":
+ if (!ids || ids.length === 0)
+ return {
+ content: [
+ {
+ type: "text",
+ text: "remove_annotations requires `ids` array",
+ },
+ ],
+ isError: true,
+ };
+ enqueueCommand(uuid, { type: "remove_annotations", ids });
+ description = `remove ${ids.length} annotation(s)`;
+ break;
+ case "highlight_text": {
+ if (!query)
+ return {
+ content: [
+ { type: "text", text: "highlight_text requires `query`" },
+ ],
+ isError: true,
+ };
+ const id = `ht_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+ enqueueCommand(uuid, {
+ type: "highlight_text",
+ id,
+ query,
+ page,
+ color,
+ content,
+ });
+ description = `highlight text "${query}"${page ? ` on page ${page}` : ""}`;
+ break;
+ }
+ case "fill_form": {
+ if (!fields || fields.length === 0)
+ return {
+ content: [
+ { type: "text", text: "fill_form requires `fields` array" },
+ ],
+ isError: true,
+ };
+ const knownFields = viewFieldNames.get(uuid);
+ const validFields: typeof fields = [];
+ const unknownNames: string[] = [];
+ for (const f of fields) {
+ if (knownFields && !knownFields.has(f.name)) {
+ unknownNames.push(f.name);
+ } else {
+ validFields.push(f);
+ }
+ }
+ if (validFields.length > 0) {
+ enqueueCommand(uuid, { type: "fill_form", fields: validFields });
+ }
+ const parts: string[] = [];
+ if (validFields.length > 0) {
+ parts.push(
+ `Filled ${validFields.length} field(s): ${validFields.map((f) => f.name).join(", ")}`,
+ );
+ }
+ if (unknownNames.length > 0) {
+ parts.push(`Unknown field(s) skipped: ${unknownNames.join(", ")}`);
+ // Only list valid names when the model got something wrong โ
+ // display_pdf already returned the full field info on open.
+ if (knownFields && knownFields.size > 0) {
+ parts.push(`Valid field names: ${[...knownFields].join(", ")}`);
+ }
+ }
+ description = parts.join(". ");
+ if (unknownNames.length > 0 && validFields.length === 0) {
+ return {
+ content: [{ type: "text", text: description }],
+ isError: true,
+ };
+ }
+ break;
+ }
+ case "get_text": {
+ const resolvedIntervals =
+ intervals ?? (page ? [{ start: page, end: page }] : [{}]);
+
+ const requestId = randomUUID();
+
+ enqueueCommand(uuid, {
+ type: "get_pages",
+ requestId,
+ intervals: resolvedIntervals,
+ getText: true,
+ getScreenshots: false,
+ });
+
+ let pageData: PageDataEntry[];
+ try {
+ await ensureViewerIsPolling(uuid);
+ pageData = await waitForPageData(requestId, signal);
+ } catch (err) {
+ return {
+ content: [
+ {
+ type: "text",
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`,
+ },
+ ],
+ isError: true,
+ };
+ }
+
+ const textParts: ContentPart[] = [];
+ for (const entry of pageData) {
+ if (entry.text != null) {
+ textParts.push({
+ type: "text",
+ text: `--- Page ${entry.page} ---\n${entry.text}`,
+ });
+ }
+ }
+ if (textParts.length === 0) {
+ textParts.push({ type: "text", text: "No text content returned" });
+ }
+ return { content: textParts };
+ }
+ case "get_screenshot": {
+ if (page == null)
+ return {
+ content: [
+ { type: "text", text: "get_screenshot requires `page`" },
+ ],
+ isError: true,
+ };
+
+ const requestId = randomUUID();
+
+ enqueueCommand(uuid, {
+ type: "get_pages",
+ requestId,
+ intervals: [{ start: page, end: page }],
+ getText: false,
+ getScreenshots: true,
+ });
+
+ let pageData: PageDataEntry[];
+ try {
+ await ensureViewerIsPolling(uuid);
+ pageData = await waitForPageData(requestId, signal);
+ } catch (err) {
+ return {
+ content: [
+ {
+ type: "text",
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`,
+ },
+ ],
+ isError: true,
+ };
+ }
+
+ const entry = pageData[0];
+ if (entry?.image) {
+ return {
+ content: [
+ {
+ type: "image",
+ data: entry.image,
+ mimeType: "image/jpeg",
+ },
+ ],
+ };
+ }
+ return {
+ content: [{ type: "text", text: "No screenshot returned" }],
+ isError: true,
+ };
+ }
+ case "save_as": {
+ const saveErr = (text: string) => ({
+ content: [{ type: "text" as const, text }],
+ isError: true as const,
+ });
+
+ let resolved: string;
+ if (savePath) {
+ // Explicit target. Same path normalisation as save_pdf โ but NOT
+ // validateUrl(), which fails on non-existent files.
+ const filePath = isFileUrl(savePath)
+ ? fileUrlToPath(savePath)
+ : isLocalPath(savePath)
+ ? decodeURIComponent(savePath)
+ : null;
+ if (!filePath)
+ return saveErr(
+ "save_as: path must be an absolute local path or file:// URL",
+ );
+ resolved = path.resolve(filePath);
+ if (!isWritablePath(resolved))
+ return saveErr(
+ `save_as refused: ${resolved} is not under a mounted ` +
+ `directory root. Only paths under directory roots ` +
+ `(or files passed as CLI args) are writable.`,
+ );
+ if (!overwrite && fs.existsSync(resolved))
+ return saveErr(
+ `File already exists: ${resolved}. ` +
+ `Set overwrite: true to replace it, or choose a different path.`,
+ );
+ } else {
+ // No target โ overwrite the original. Same gate as the viewer's
+ // save button: isWritablePath + OS-level W_OK (so we don't try
+ // on read-only mounts). Remote PDFs have no source path stored.
+ const source = viewSourcePaths.get(uuid);
+ if (!source)
+ return saveErr(
+ "save_as: no `path` given and this viewer has no local source " +
+ "file to overwrite (it's a remote URL, or the viewUUID is " +
+ "stale/unknown). Provide an explicit `path`.",
+ );
+ if (!overwrite)
+ return saveErr(
+ `save_as: omitting \`path\` overwrites the original ` +
+ `(${source}). Set overwrite: true to confirm.`,
+ );
+ if (!isWritablePath(source))
+ return saveErr(
+ `save_as refused: ${source} is not writable (the viewer's ` +
+ `save button is hidden for the same reason).`,
+ );
+ try {
+ await fs.promises.access(source, fs.constants.W_OK);
+ } catch {
+ return saveErr(
+ `save_as refused: ${source} is not writable at the OS level ` +
+ `(read-only mount or insufficient permissions).`,
+ );
+ }
+ resolved = source;
+ }
+
+ const requestId = randomUUID();
+ enqueueCommand(uuid, { type: "save_as", requestId });
+ let data: string;
+ try {
+ await ensureViewerIsPolling(uuid);
+ data = await waitForSaveData(requestId, signal);
+ } catch (err) {
+ return saveErr(
+ `save_as failed: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+ try {
+ const bytes = Buffer.from(data, "base64");
+ await fs.promises.writeFile(resolved, bytes);
+ return {
+ content: [
+ {
+ type: "text",
+ text: `Saved annotated PDF to ${resolved} (${bytes.length} bytes)`,
+ },
+ ],
+ };
+ } catch (err) {
+ return saveErr(
+ `save_as: failed to write ${resolved}: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+ }
+ case "get_viewer_state": {
+ const requestId = randomUUID();
+ enqueueCommand(uuid, { type: "get_viewer_state", requestId });
+ let state: string;
+ try {
+ await ensureViewerIsPolling(uuid);
+ state = await waitForViewerState(requestId, signal);
+ } catch (err) {
+ return {
+ content: [
+ {
+ type: "text",
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`,
+ },
+ ],
+ isError: true,
+ };
+ }
+ return { content: [{ type: "text", text: state }] };
+ }
+ default:
+ return {
+ content: [{ type: "text", text: `Unknown action: ${action}` }],
+ isError: true,
+ };
+ }
+ return {
+ content: [{ type: "text", text: `Queued: ${description}` }],
+ };
+ }
+
+ // Tool: interact - Interact with an existing PDF viewer
+ server.registerTool(
+ "interact",
+ {
+ title: "Interact with PDF",
+ description: `Interact with a PDF viewer: annotate, navigate, search, extract text/screenshots, fill forms.
+IMPORTANT: viewUUID must be the exact UUID returned by display_pdf (e.g. "a1b2c3d4-..."). Do NOT use arbitrary strings.
+
+**BATCHING**: Send multiple commands in one call via \`commands\` array. Commands run sequentially; results are returned in the same order, one content item per command. If a command fails, the batch stops there and that command's slot contains text starting with \`ERROR\` โ content.length tells you how far it got. TIP: End with \`get_screenshot\` to verify your changes.
+
+**ANNOTATION** โ add_annotations with array of annotation objects. Each needs: id (unique string), type, page (1-indexed).
+
+**COORDINATE SYSTEM**: PDF points (1pt = 1/72in), origin at page TOP-LEFT corner. X increases rightward, Y increases downward.
+- US Letter = 612ร792pt. Margins: topโy=50, bottomโy=742, leftโx=72, rightโx=540, centerโ(306, 396).
+- Rectangle/circle/stamp x,y is the TOP-LEFT corner. To place a 200ร30 box at the TOP of the page: x=72, y=50, width=200, height=30.
+- For highlights/underlines, each rect's y is the TOP of the highlighted region.
+
+Annotation types:
+โข highlight: rects:[{x,y,width,height}], color?, content? โข underline: rects:[{x,y,w,h}], color?
+โข strikethrough: rects:[{x,y,w,h}], color? โข note: x, y, content, color?
+โข rectangle: x, y, width, height, color?, fillColor?, rotation? โข circle: x, y, width, height, color?, fillColor?
+โข line: x1, y1, x2, y2, color? โข freetext: x, y, content, fontSize?, color?
+โข stamp: x, y, label (any text, e.g. APPROVED, DRAFT, CONFIDENTIAL), color?, rotation?
+โข image: imageUrl (required), x?, y?, width?, height?, mimeType?, rotation?, aspect? โ places an image (signature, logo, etc.) on the page. Pass a local file path or HTTPS URL (NO data: URIs, NO base64). Width/height auto-detected if omitted. Users can also drag & drop images directly onto the viewer.
+
+TIP: For text annotations, prefer highlight_text (auto-finds text) over manual rects.
+
+Example โ add a signature image and a stamp, then screenshot to verify:
+\`\`\`json
+{"viewUUID":"โฆ","commands":[
+ {"action":"add_annotations","annotations":[
+ {"id":"sig1","type":"image","page":1,"x":72,"y":700,"imageUrl":"/path/to/signature.png"},
+ {"id":"s1","type":"stamp","page":1,"x":300,"y":400,"label":"APPROVED"}
+ ]},
+ {"action":"get_screenshot","page":1}
+]}
+\`\`\`
+
+โข highlight_text: auto-find and highlight text (query, page?, color?, content?)
+โข update_annotations: partial update (id+type required) โข remove_annotations: remove by ids
+
+**NAVIGATION**: navigate (page), search (query), find (query, silent), search_navigate (matchIndex), zoom (scale 0.5โ3.0)
+
+**TEXT/SCREENSHOTS**:
+โข get_text: extract text from pages. Optional \`page\` for single page, or \`intervals\` for ranges [{start?,end?}]. Max 20 pages.
+โข get_screenshot: capture a single page as PNG image. Requires \`page\`.
+โข get_viewer_state: snapshot of the live viewer โ JSON {currentPage, pageCount, zoom, displayMode, selectedAnnotationIds, selection:{text,contextBefore,contextAfter,boundingRect}|null}. Use this to read what the user has selected or which page they're on.
+
+**FORMS** โ fill_form: fill fields with \`fields\` array of {name, value}.
+
+**SAVE** โ save_as: write the annotated PDF (annotations + form values) to a file. Pass \`path\` (absolute path or file://) for a new location, or omit \`path\` to overwrite the original. Set \`overwrite: true\` to replace an existing file (always required when omitting \`path\`).`,
+ inputSchema: z.object({
+ viewUUID: z
+ .string()
+ .describe(
+ "The viewUUID of the PDF viewer (from display_pdf result)",
+ ),
+ // Single-command mode (backwards-compatible)
+ action: z
+ .enum([
+ "navigate",
+ "search",
+ "find",
+ "search_navigate",
+ "zoom",
+ "add_annotations",
+ "update_annotations",
+ "remove_annotations",
+ "highlight_text",
+ "fill_form",
+ "get_text",
+ "get_screenshot",
+ "get_viewer_state",
+ "save_as",
+ ])
+ .optional()
+ .describe(
+ "Action to perform (for single command). Use `commands` array for batching.",
+ ),
+ page: z
+ .number()
+ .min(1)
+ .optional()
+ .describe(
+ "Page number (for navigate, highlight_text, get_screenshot, get_text)",
+ ),
+ query: z
+ .string()
+ .optional()
+ .describe("Search text (for search / find / highlight_text)"),
+ matchIndex: z
+ .number()
+ .min(0)
+ .optional()
+ .describe("Match index (for search_navigate)"),
+ scale: z
+ .number()
+ .min(0.5)
+ .max(3.0)
+ .optional()
+ .describe("Zoom scale, 1.0 = 100% (for zoom)"),
+ annotations: z
+ .array(z.record(z.string(), z.any()))
+ .optional()
+ .describe(
+ "Annotation objects (see types in description). Each needs: id, type, page. For update_annotations only id+type are required.",
+ ),
+ ids: z
+ .array(z.string())
+ .optional()
+ .describe("Annotation IDs (for remove_annotations)"),
+ color: z
+ .string()
+ .optional()
+ .describe("Color override (for highlight_text)"),
+ content: z
+ .string()
+ .optional()
+ .describe("Tooltip/note content (for highlight_text)"),
+ fields: z
+ .array(FormField)
+ .optional()
+ .describe(
+ "Form fields to fill (for fill_form): { name, value } where value is string or boolean",
+ ),
+ intervals: z
+ .array(PageInterval)
+ .optional()
+ .describe(
+ "Page ranges for get_text. Each has optional start/end. [{start:1,end:5}], [{}] = all pages. Max 20 pages.",
+ ),
+ path: z
+ .string()
+ .optional()
+ .describe(
+ "Target file path for save_as. Absolute path or file:// URL. Omit to overwrite the original file (requires overwrite: true).",
+ ),
+ overwrite: z
+ .boolean()
+ .optional()
+ .describe("Overwrite if file exists (for save_as). Default false."),
+ // Batch mode
+ commands: z
+ .array(InteractCommandSchema)
+ .optional()
+ .describe(
+ "Array of commands to execute sequentially. More efficient than separate calls. Tip: end with get_pages+getScreenshots to verify changes.",
+ ),
+ }),
+ },
+ async (
+ {
+ viewUUID: uuid,
+ action,
+ page,
+ query,
+ matchIndex,
+ scale,
+ annotations,
+ ids,
+ color,
+ content,
+ fields,
+ intervals,
+ path: savePath,
+ overwrite,
+ commands,
+ },
+ extra,
+ ): Promise => {
+ // Build the list of commands to process
+ const commandList: InteractCommand[] = commands
+ ? commands
+ : action
+ ? [
+ {
+ action,
+ page,
+ query,
+ matchIndex,
+ scale,
+ annotations,
+ ids,
+ color,
+ content,
+ fields,
+ intervals,
+ path: savePath,
+ overwrite,
+ },
+ ]
+ : [];
+
+ if (commandList.length === 0) {
+ return {
+ content: [
+ {
+ type: "text",
+ text: "No action or commands specified. Provide either `action` (single command) or `commands` (batch).",
+ },
+ ],
+ isError: true,
+ };
+ }
+
+ // 1:1 content array โ content[i] is the result of commands[i].
+ //
+ // For multi-step batches we do NOT set isError on a step failure:
+ // LocalAgentMode SDK 2.1.87 collapses isError:true results to a
+ // bare string of content[0].text, which would drop any images
+ // from earlier successful steps. Instead, the failed step's slot
+ // is text starting with "ERROR", and the batch stops there โ the
+ // model reads content.length to see how far it got.
+ //
+ // Single-command calls have no prior results to lose, so they
+ // keep isError:true. The SDK's flatten-to-content[0].text is
+ // exactly the ERROR text we want it to see.
+ const allContent: ContentPart[] = [];
+ let failedAt = -1;
+ const t0 = Date.now();
+
+ for (let i = 0; i < commandList.length; i++) {
+ const result = await processInteractCommand(
+ uuid,
+ commandList[i],
+ extra.mcpReq.signal,
+ );
+ if (result.isError) {
+ const errText = result.content
+ .map((c) => (c.type === "text" ? c.text : null))
+ .filter((t) => t != null)
+ .join(" โ ");
+ // Normalize the prefix โ processInteractCommand's catch blocks
+ // are inconsistent ("Error: ...", "add_annotations: ...", etc.)
+ const stripped = errText.replace(/^error:\s*/i, "");
+ allContent.push({
+ type: "text",
+ text:
+ commandList.length > 1
+ ? `ERROR at step ${i + 1}/${commandList.length} (${commandList[i].action}): ${stripped}`
+ : `ERROR: ${stripped}`,
+ });
+ failedAt = i;
+ console.error(
+ `[interact] uuid=${uuid} step ${i + 1}/${commandList.length} ` +
+ `(${commandList[i].action}) failed after ${Date.now() - t0}ms: ${stripped}`,
+ );
+ break;
+ }
+ // Squash multi-part successes (e.g. get_text returning multiple
+ // text blocks) into one slot so 1:1 indexing holds. Preserve a
+ // lone image as-is โ that's the screenshot the model wants.
+ if (result.content.length === 1) {
+ allContent.push(result.content[0]);
+ } else {
+ const texts = result.content
+ .map((c) => (c.type === "text" ? c.text : null))
+ .filter((t) => t != null);
+ allContent.push({ type: "text", text: texts.join("\n") });
+ }
+ }
+
+ return {
+ content: allContent,
+ // isError flattens to a string in some SDKs, losing the array
+ // shape. Only set it when there was never going to be an array
+ // โ single command, no positional contract to break.
+ ...(failedAt >= 0 && commandList.length === 1
+ ? { isError: true }
+ : {}),
+ };
+ },
+ );
+
+ // Tool: submit_page_data (app-only) - Client submits rendered page data
+ registerAppTool(
+ server,
+ "submit_page_data",
+ {
+ title: "Submit Page Data",
+ description:
+ "Submit rendered page data for a get_pages request (used by viewer). The model should NOT call this tool directly.",
+ inputSchema: z.object({
+ requestId: z
+ .string()
+ .describe("The request ID from the get_pages command"),
+ pages: z
+ .array(
+ z.object({
+ page: z.number(),
+ text: z.string().optional(),
+ image: z.string().optional().describe("Base64 PNG image data"),
+ }),
+ )
+ .describe("Page data entries"),
+ }),
+ _meta: { ui: { visibility: ["app"] } },
+ },
+ async ({ requestId, pages }): Promise => {
+ const settle = pendingPageRequests.get(requestId);
+ if (settle) {
+ settle(pages);
+ return {
+ content: [
+ { type: "text", text: `Submitted ${pages.length} page(s)` },
+ ],
+ };
+ }
+ return {
+ content: [
+ { type: "text", text: `No pending request for ${requestId}` },
+ ],
+ isError: true,
+ };
+ },
+ );
+
+ // Tool: submit_save_data (app-only) - Viewer submits annotated PDF bytes
+ registerAppTool(
+ server,
+ "submit_save_data",
+ {
+ title: "Submit Save Data",
+ description:
+ "Submit annotated PDF bytes for a save_as request (used by viewer). The model should NOT call this tool directly.",
+ inputSchema: z.object({
+ requestId: z
+ .string()
+ .describe("The request ID from the save_as command"),
+ data: z.string().optional().describe("Base64-encoded PDF bytes"),
+ error: z
+ .string()
+ .optional()
+ .describe("Error message if the viewer failed to build bytes"),
+ }),
+ _meta: { ui: { visibility: ["app"] } },
+ },
+ async ({ requestId, data, error }): Promise => {
+ const settle = pendingSaveRequests.get(requestId);
+ if (!settle) {
+ return {
+ content: [
+ { type: "text", text: `No pending request for ${requestId}` },
+ ],
+ isError: true,
+ };
+ }
+ if (error || !data) {
+ settle(new Error(error || "Viewer returned no data"));
+ } else {
+ settle(data);
+ }
+ return { content: [{ type: "text", text: "Submitted" }] };
+ },
+ );
+
+ // Tool: submit_viewer_state (app-only) - Viewer reports its live state
+ registerAppTool(
+ server,
+ "submit_viewer_state",
+ {
+ title: "Submit Viewer State",
+ description:
+ "Submit a viewer-state snapshot for a get_viewer_state request (used by viewer). The model should NOT call this tool directly.",
+ inputSchema: z.object({
+ requestId: z
+ .string()
+ .describe("The request ID from the get_viewer_state command"),
+ state: z
+ .string()
+ .optional()
+ .describe("JSON-encoded viewer state snapshot"),
+ error: z
+ .string()
+ .optional()
+ .describe("Error message if the viewer failed to read state"),
+ }),
+ _meta: { ui: { visibility: ["app"] } },
+ },
+ async ({ requestId, state, error }): Promise => {
+ const settle = pendingStateRequests.get(requestId);
+ if (!settle) {
+ return {
+ content: [
+ { type: "text", text: `No pending request for ${requestId}` },
+ ],
+ isError: true,
+ };
+ }
+ if (error || !state) {
+ settle(new Error(error || "Viewer returned no state"));
+ } else {
+ settle(state);
+ }
+ return { content: [{ type: "text", text: "Submitted" }] };
+ },
+ );
+
+ // Tool: poll_pdf_commands (app-only) - Poll for pending commands
+ registerAppTool(
+ server,
+ "poll_pdf_commands",
+ {
+ title: "Poll PDF Commands",
+ description:
+ "Poll for pending commands for a PDF viewer. The model should NOT call this tool directly.",
+ inputSchema: z.object({
+ viewUUID: z.string().describe("The viewUUID of the PDF viewer"),
+ }),
+ _meta: { ui: { visibility: ["app"] } },
+ },
+ async ({ viewUUID: uuid }): Promise => {
+ viewsPolled.add(uuid);
+ // If commands are already queued, wait briefly to let more accumulate
+ if (commandQueues.has(uuid)) {
+ await new Promise((r) => setTimeout(r, POLL_BATCH_WAIT_MS));
+ } else {
+ // Long-poll: wait for commands to arrive or timeout
+ await new Promise((resolve) => {
+ const timer = setTimeout(() => {
+ pollWaiters.delete(uuid);
+ resolve();
+ }, LONG_POLL_TIMEOUT_MS);
+ // Cancel any existing waiter for this uuid
+ const prev = pollWaiters.get(uuid);
+ if (prev) prev();
+ pollWaiters.set(uuid, () => {
+ clearTimeout(timer);
+ resolve();
+ });
+ });
+ // After waking, wait briefly for batching
+ if (commandQueues.has(uuid)) {
+ await new Promise((r) => setTimeout(r, POLL_BATCH_WAIT_MS));
+ }
+ }
+ const commands = dequeueCommands(uuid);
+ return {
+ content: [{ type: "text", text: `${commands.length} command(s)` }],
+ structuredContent: { commands },
+ };
+ },
+ );
+ } // end if (!disableInteract)
+
+ // Tool: save_pdf (app-only) - Save annotated PDF back to local file
+ registerAppTool(
+ server,
+ "save_pdf",
+ {
+ title: "Save PDF",
+ description:
+ "Save annotated PDF bytes back to a local file. The model should NOT call this tool directly โ use interact with action: save_as instead.",
+ inputSchema: z.object({
+ url: z.string().describe("Original PDF URL or local file path"),
+ data: z.string().describe("Base64-encoded PDF bytes"),
+ }),
+ outputSchema: z.object({
+ filePath: z.string(),
+ mtimeMs: z.number(),
+ }),
+ _meta: { ui: { visibility: ["app"] } },
+ },
+ async ({ url, data }): Promise => {
+ const validation = validateUrl(url);
+ if (!validation.valid) {
+ return {
+ content: [{ type: "text", text: validation.error! }],
+ isError: true,
+ };
+ }
+ const filePath = isFileUrl(url)
+ ? fileUrlToPath(url)
+ : isLocalPath(url)
+ ? decodeURIComponent(url)
+ : null;
+ if (!filePath) {
+ return {
+ content: [
+ { type: "text", text: "Save is only supported for local files" },
+ ],
+ isError: true,
+ };
+ }
+ const resolved = path.resolve(filePath);
+ // Enforce the same write scope the display_pdf writable flag uses.
+ // The viewer hides the save button for non-writable files, but we
+ // must not trust the client: a direct save_pdf call should also refuse.
+ if (!isWritablePath(resolved)) {
+ return {
+ content: [
+ {
+ type: "text",
+ text:
+ "Save refused: file is not under a mounted directory root " +
+ "and was not passed as a CLI argument. MCP file roots are " +
+ "read-only (typically uploaded copies the client doesn't " +
+ "expect to change).",
+ },
+ ],
+ isError: true,
+ };
+ }
+ try {
+ const bytes = Buffer.from(data, "base64");
+ await fs.promises.writeFile(resolved, bytes);
+ const { mtimeMs } = await fs.promises.stat(resolved);
+ // Don't suppress file_changed here โ the saving viewer will recognise
+ // its own mtime, while other viewers on the same file correctly get
+ // notified that their content is stale.
+ return {
+ content: [{ type: "text", text: `Saved to ${filePath}` }],
+ structuredContent: { filePath: resolved, mtimeMs },
+ };
+ } catch (err) {
+ return {
+ content: [
+ {
+ type: "text",
+ text: `Failed to save: ${err instanceof Error ? err.message : String(err)}`,
+ },
+ ],
+ isError: true,
+ };
+ }
+ },
+ );
+
// Resource: UI HTML
registerAppResource(
server,
@@ -638,13 +2909,30 @@ Accepts:
RESOURCE_URI,
{ mimeType: RESOURCE_MIME_TYPE },
async (): Promise => {
- const html = await fs.promises.readFile(
+ const html = (cachedAppHtml ??= await fs.promises.readFile(
path.join(DIST_DIR, "mcp-app.html"),
"utf-8",
- );
+ ));
return {
contents: [
- { uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html },
+ {
+ uri: RESOURCE_URI,
+ mimeType: RESOURCE_MIME_TYPE,
+ text: html,
+ _meta: {
+ ui: {
+ permissions: { clipboardWrite: {} },
+ csp: {
+ // pdf.js loads the Standard-14 fonts TWO ways:
+ // - fetch()s the .ttf bytes โ connect-src
+ // - creates FontFace('name', 'url(...)') โ font-src
+ // resourceDomains maps to font-src; we need both.
+ connectDomains: [STANDARD_FONT_ORIGIN],
+ resourceDomains: [STANDARD_FONT_ORIGIN],
+ },
+ },
+ },
+ },
],
};
},
diff --git a/examples/pdf-server/src/annotation-panel.ts b/examples/pdf-server/src/annotation-panel.ts
new file mode 100644
index 0000000..9fd4457
--- /dev/null
+++ b/examples/pdf-server/src/annotation-panel.ts
@@ -0,0 +1,1177 @@
+/**
+ * Annotation Panel โ floating/docked sidebar showing all annotations and
+ * form fields, grouped by page in accordion sections. Handles panel
+ * positioning, resize, drag-to-reposition, per-item cards, and the
+ * reset/clear-all actions.
+ *
+ * All coupling to mcp-app.ts flows through {@link PanelDeps} supplied to
+ * {@link initAnnotationPanel}. Shared Maps/Sets come from viewer-state.ts.
+ */
+
+import type * as pdfjsLib from "pdfjs-dist";
+import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps";
+import type { PdfAnnotationDef } from "./pdf-annotations.js";
+import {
+ type TrackedAnnotation,
+ annotationMap,
+ formFieldValues,
+ pdfBaselineFormValues,
+ selectedAnnotationIds,
+ fieldNameToIds,
+ fieldNameToPage,
+ fieldNameToLabel,
+ fieldNameToOrder,
+ undoStack,
+ redoStack,
+ searchBarEl,
+ formLayerEl,
+} from "./viewer-state.js";
+
+const log = {
+ error: console.error.bind(console, "[PDF-VIEWER]"),
+};
+
+// =============================================================================
+// Panel DOM Elements
+// =============================================================================
+
+/** Floating panel container. Exported โ mcp-app.ts reads .classList. */
+export const annotationsPanelEl = document.getElementById("annotation-panel")!;
+/** Scrollable list of accordion sections. Exported โ selectAnnotation queries it. */
+export const annotationsPanelListEl = document.getElementById(
+ "annotation-panel-list",
+)!;
+const annotationsPanelCountEl = document.getElementById(
+ "annotation-panel-count",
+)!;
+const annotationsPanelCloseBtn = document.getElementById(
+ "annotation-panel-close",
+) as HTMLButtonElement;
+const annotationsPanelResetBtn = document.getElementById(
+ "annotation-panel-reset",
+) as HTMLButtonElement;
+const annotationsPanelClearAllBtn = document.getElementById(
+ "annotation-panel-clear-all",
+) as HTMLButtonElement;
+const annotationsBtn = document.getElementById(
+ "annotations-btn",
+) as HTMLButtonElement;
+const annotationsBadgeEl = document.getElementById(
+ "annotations-badge",
+) as HTMLElement;
+
+// =============================================================================
+// Panel State (exported โ mcp-app.ts reads .open, writes .openAccordionSection)
+// =============================================================================
+
+/** Which corner the floating panel is anchored to. */
+type PanelCorner = "top-right" | "top-left" | "bottom-right" | "bottom-left";
+
+export const panelState = {
+ /** Whether the panel is currently visible. Read by search open/close + debug. */
+ open: false,
+ /** Which accordion section is open (e.g. "page-3"). Written by selectAnnotation. */
+ openAccordionSection: null as string | null,
+};
+
+/** null = user hasn't manually toggled; true/false = manual preference */
+let annotationPanelUserPref: boolean | null = null;
+/** Whether the user has ever interacted with accordion sections (prevents auto-open after explicit collapse). */
+let accordionUserInteracted = false;
+let floatingPanelCorner: PanelCorner = "top-right";
+
+// =============================================================================
+// Dependencies injected by mcp-app.ts
+// =============================================================================
+
+/**
+ * Callbacks and live-state getters supplied by mcp-app.ts. Scalars like
+ * `currentPage` and `pdfDocument` are reassigned throughout mcp-app.ts;
+ * a getter here avoids wrapping every one of their ~100 use sites.
+ */
+export interface PanelDeps {
+ // Live scalar state (read-only from panel's side)
+ state: () => {
+ currentPage: number;
+ isDirty: boolean;
+ pdfDocument: pdfjsLib.PDFDocumentProxy | null;
+ pdfBaselineAnnotations: PdfAnnotationDef[];
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ cachedFieldObjects: Record | null;
+ searchOpen: boolean;
+ };
+ // Callbacks into mcp-app.ts
+ renderPage: () => void;
+ goToPage: (page: number) => void;
+ selectAnnotation: (id: string | null) => void;
+ persistAnnotations: () => void;
+ removeAnnotation: (id: string) => void;
+ requestFitToContent: () => void;
+ updatePageContext: () => void;
+ setFocusedField: (name: string | null) => void;
+ // App bridge
+ sendMessage: (msg: {
+ role: "user";
+ content: Array<{ type: "text"; text: string }>;
+ }) => Promise;
+ getHostContext: () => McpUiHostContext | undefined;
+}
+
+let deps!: PanelDeps;
+
+// =============================================================================
+// Floating panel positioning
+// =============================================================================
+
+/** Get inset margins for the floating panel (safe area + padding). */
+function getFloatingPanelInsets(): {
+ top: number;
+ right: number;
+ bottom: number;
+ left: number;
+} {
+ const insets = { top: 4, right: 4, bottom: 4, left: 4 };
+ const ctx = deps.getHostContext();
+ if (ctx?.safeAreaInsets) {
+ insets.top += ctx.safeAreaInsets.top;
+ insets.right += ctx.safeAreaInsets.right;
+ insets.bottom += ctx.safeAreaInsets.bottom;
+ insets.left += ctx.safeAreaInsets.left;
+ }
+ return insets;
+}
+
+/** Position the floating panel based on its anchored corner. */
+export function applyFloatingPanelPosition(): void {
+ const el = annotationsPanelEl;
+ // Reset all position props
+ el.style.top = "";
+ el.style.bottom = "";
+ el.style.left = "";
+ el.style.right = "";
+
+ const insets = getFloatingPanelInsets();
+
+ // When search bar is visible and panel is anchored top-right, offset below it
+ const searchBarExtra =
+ deps.state().searchOpen && floatingPanelCorner === "top-right"
+ ? searchBarEl.offsetHeight + 2
+ : 0;
+
+ const isRight = floatingPanelCorner.includes("right");
+ const isBottom = floatingPanelCorner.includes("bottom");
+
+ if (isBottom) {
+ el.style.bottom = `${insets.bottom}px`;
+ } else {
+ el.style.top = `${insets.top + searchBarExtra}px`;
+ }
+ if (isRight) {
+ el.style.right = `${insets.right}px`;
+ } else {
+ el.style.left = `${insets.left}px`;
+ }
+
+ // Update resize handle position based on anchorage
+ updateResizeHandlePosition();
+}
+
+/** Position the resize handle on the correct edge based on panel anchorage. */
+function updateResizeHandlePosition(): void {
+ const resizeHandle = document.getElementById("annotation-panel-resize");
+ if (!resizeHandle) return;
+ const isRight = floatingPanelCorner.includes("right");
+ if (isRight) {
+ // Panel is on the right โ resize handle on the left edge
+ resizeHandle.style.left = "-3px";
+ resizeHandle.style.right = "";
+ } else {
+ // Panel is on the left โ resize handle on the right edge
+ resizeHandle.style.left = "";
+ resizeHandle.style.right = "-3px";
+ }
+}
+
+/** Auto-dock the floating panel to the opposite side if it overlaps selected annotations. */
+export function autoDockPanel(): void {
+ const panelRect = annotationsPanelEl.getBoundingClientRect();
+ let overlaps = false;
+ for (const selId of selectedAnnotationIds) {
+ const tracked = annotationMap.get(selId);
+ if (!tracked) continue;
+ for (const el of tracked.elements) {
+ const elRect = el.getBoundingClientRect();
+ // Check overlap
+ if (
+ panelRect.left < elRect.right &&
+ panelRect.right > elRect.left &&
+ panelRect.top < elRect.bottom &&
+ panelRect.bottom > elRect.top
+ ) {
+ overlaps = true;
+ break;
+ }
+ }
+ if (overlaps) break;
+ }
+ if (overlaps) {
+ // Swap left โ right
+ if (floatingPanelCorner.includes("right")) {
+ floatingPanelCorner = floatingPanelCorner.replace(
+ "right",
+ "left",
+ ) as PanelCorner;
+ } else {
+ floatingPanelCorner = floatingPanelCorner.replace(
+ "left",
+ "right",
+ ) as PanelCorner;
+ }
+ applyFloatingPanelPosition();
+ }
+}
+
+export function setAnnotationPanelOpen(open: boolean): void {
+ panelState.open = open;
+ annotationsBtn.classList.toggle("active", open);
+ updateAnnotationsBadge();
+
+ // Always use floating panel (both inline and fullscreen)
+ annotationsPanelEl.classList.toggle("floating", true);
+ annotationsPanelEl.style.display = open ? "" : "none";
+ if (open) {
+ applyFloatingPanelPosition();
+ renderAnnotationPanel();
+ }
+ deps.requestFitToContent();
+}
+
+function toggleAnnotationPanel(): void {
+ annotationPanelUserPref = !panelState.open;
+ try {
+ localStorage.setItem(
+ "pdf-annotation-panel",
+ annotationPanelUserPref ? "open" : "closed",
+ );
+ } catch {
+ /* ignore */
+ }
+ setAnnotationPanelOpen(annotationPanelUserPref);
+}
+
+// =============================================================================
+// Field & item state helpers
+// =============================================================================
+
+/**
+ * Derived state of a form field relative to the PDF baseline.
+ * Not stored โ computed on demand by comparing formFieldValues to
+ * pdfBaselineFormValues.
+ */
+type FieldState =
+ | "unchanged" // current === baseline (came from the PDF, untouched)
+ | "modified" // baseline exists but current differs
+ | "cleared" // baseline exists but current is absent/empty
+ | "added"; // no baseline โ user-filled or fill_form
+
+function fieldState(name: string): FieldState {
+ const cur = formFieldValues.get(name);
+ const base = pdfBaselineFormValues.get(name);
+ if (base === undefined) return "added";
+ if (cur === undefined || cur === "" || cur === false) return "cleared";
+ return cur === base ? "unchanged" : "modified";
+}
+
+/** All field names that should appear in the panel: current โช baseline.
+ * Cleared baseline fields remain visible (crossed out) so they can be
+ * reverted individually. */
+function panelFieldNames(): Set {
+ return new Set([...formFieldValues.keys(), ...pdfBaselineFormValues.keys()]);
+}
+
+/** Baseline annotations the user has deleted. Shown crossed-out in the panel
+ * (mirroring cleared form fields) so they can be reverted, and so save knows
+ * to strip their refs from /Annots. */
+function removedBaselineAnnotations(): PdfAnnotationDef[] {
+ return deps
+ .state()
+ .pdfBaselineAnnotations.filter((a) => !annotationMap.has(a.id));
+}
+
+/** Total count of annotations + form fields for the sidebar badge.
+ * Uses the union so cleared baseline items still contribute. */
+function sidebarItemCount(): number {
+ return (
+ annotationMap.size +
+ removedBaselineAnnotations().length +
+ panelFieldNames().size
+ );
+}
+
+export function updateAnnotationsBadge(): void {
+ const count = sidebarItemCount();
+ if (count > 0 && !panelState.open) {
+ annotationsBadgeEl.textContent = String(count);
+ annotationsBadgeEl.style.display = "";
+ } else {
+ annotationsBadgeEl.style.display = "none";
+ }
+ // Show/hide the toolbar button based on whether items exist
+ annotationsBtn.style.display = count > 0 ? "" : "none";
+ // Auto-close panel when all items are gone
+ if (count === 0 && panelState.open) {
+ setAnnotationPanelOpen(false);
+ }
+}
+
+// =============================================================================
+// Label / preview helpers
+// =============================================================================
+
+/** Human-readable label for an annotation type (used in sidebar). */
+export function getAnnotationLabel(def: PdfAnnotationDef): string {
+ switch (def.type) {
+ case "highlight":
+ return "Highlight";
+ case "underline":
+ return "Underline";
+ case "strikethrough":
+ return "Strikethrough";
+ case "note":
+ return "Note";
+ case "freetext":
+ return "Text";
+ case "rectangle":
+ return "Rectangle";
+ case "stamp":
+ return `Stamp: ${def.label}`;
+ case "circle":
+ return "Circle";
+ case "line":
+ return "Line";
+ case "image":
+ return "Image";
+ case "imported":
+ return `${def.subtype} (from PDF)`;
+ }
+}
+
+/** Preview text for an annotation (shown after the label). */
+export function getAnnotationPreview(def: PdfAnnotationDef): string {
+ switch (def.type) {
+ case "note":
+ case "freetext":
+ return def.content || "";
+ case "highlight":
+ return def.content || "";
+ case "stamp":
+ return "";
+ case "image":
+ return "";
+ default:
+ return "";
+ }
+}
+
+export function getAnnotationColor(def: PdfAnnotationDef): string {
+ if ("color" in def && def.color) return def.color;
+ switch (def.type) {
+ case "highlight":
+ return "rgba(255, 255, 0, 0.7)";
+ case "underline":
+ return "#ff0000";
+ case "strikethrough":
+ return "#ff0000";
+ case "note":
+ return "#f5a623";
+ case "rectangle":
+ return "#0066cc";
+ case "freetext":
+ return "#333";
+ case "stamp":
+ return "#cc0000";
+ case "circle":
+ return "#0066cc";
+ case "line":
+ return "#333";
+ case "image":
+ return "#999";
+ case "imported":
+ return "#666";
+ }
+}
+
+/** Return a human-readable label for a form field name. */
+export function getFormFieldLabel(name: string): string {
+ // Prefer the PDF's TU (alternativeText) if available
+ const alt = fieldNameToLabel.get(name);
+ if (alt) return alt;
+ // If the name looks mechanical (contains brackets, dots, or is all-caps with underscores),
+ // just show "Field" as a generic fallback
+ if (/[[\]().]/.test(name) || /^[A-Z0-9_]+$/.test(name)) {
+ return "Field";
+ }
+ return name;
+}
+
+function getAnnotationY(def: PdfAnnotationDef): number {
+ if ("y" in def && typeof def.y === "number") return def.y;
+ if ("rects" in def && def.rects.length > 0) return def.rects[0].y;
+ // LineAnnotation has only x1/y1/x2/y2 โ sort by the higher endpoint
+ // (higher internal-y = closer to page top).
+ if ("y1" in def) return Math.max(def.y1, def.y2);
+ return 0;
+}
+
+// =============================================================================
+// Panel rendering
+// =============================================================================
+
+export function renderAnnotationPanel(): void {
+ if (!panelState.open) return;
+
+ annotationsPanelCountEl.textContent = String(sidebarItemCount());
+ annotationsPanelResetBtn.disabled = !deps.state().isDirty;
+ annotationsPanelClearAllBtn.disabled = sidebarItemCount() === 0;
+
+ // Group annotations by page, sorted by Y position within each page
+ const byPage = new Map();
+ for (const tracked of annotationMap.values()) {
+ const page = tracked.def.page;
+ if (!byPage.has(page)) byPage.set(page, []);
+ byPage.get(page)!.push(tracked);
+ }
+
+ // Removed baseline annotations: still listed (crossed-out) so they can be
+ // reverted before save strips them from the file.
+ const removedByPage = new Map();
+ for (const def of removedBaselineAnnotations()) {
+ if (!removedByPage.has(def.page)) removedByPage.set(def.page, []);
+ removedByPage.get(def.page)!.push(def);
+ }
+
+ // Group form fields by page โ iterate the UNION so cleared baseline
+ // fields remain visible (crossed out) with a per-item revert button.
+ const fieldsByPage = new Map();
+ for (const name of panelFieldNames()) {
+ const page = fieldNameToPage.get(name) ?? 1;
+ if (!fieldsByPage.has(page)) fieldsByPage.set(page, []);
+ fieldsByPage.get(page)!.push(name);
+ }
+ // Sort fields by their intrinsic document order within each page
+ for (const names of fieldsByPage.values()) {
+ names.sort(
+ (a, b) => (fieldNameToOrder.get(a) ?? 0) - (fieldNameToOrder.get(b) ?? 0),
+ );
+ }
+
+ // Collect all pages that have annotations or form fields
+ const allPages = new Set([
+ ...byPage.keys(),
+ ...removedByPage.keys(),
+ ...fieldsByPage.keys(),
+ ]);
+ const sortedPages = [...allPages].sort((a, b) => a - b);
+
+ // Sort annotations within each page by Y position (descending = top-first in PDF coords)
+ for (const annotations of byPage.values()) {
+ annotations.sort((a, b) => getAnnotationY(b.def) - getAnnotationY(a.def));
+ }
+
+ annotationsPanelListEl.innerHTML = "";
+
+ const { currentPage } = deps.state();
+
+ // Auto-open section for current page only on first render (before user interaction)
+ if (panelState.openAccordionSection === null && !accordionUserInteracted) {
+ if (allPages.has(currentPage)) {
+ panelState.openAccordionSection = `page-${currentPage}`;
+ } else if (sortedPages.length > 0) {
+ panelState.openAccordionSection = `page-${sortedPages[0]}`;
+ }
+ }
+
+ for (const pageNum of sortedPages) {
+ const sectionKey = `page-${pageNum}`;
+ const isOpen = panelState.openAccordionSection === sectionKey;
+ const annotations = byPage.get(pageNum) ?? [];
+ const removed = removedByPage.get(pageNum) ?? [];
+ const fields = fieldsByPage.get(pageNum) ?? [];
+ const itemCount = annotations.length + removed.length + fields.length;
+
+ appendAccordionSection(
+ `Page ${pageNum} (${itemCount})`,
+ sectionKey,
+ isOpen,
+ pageNum === currentPage,
+ (body) => {
+ // Form fields first
+ for (const name of fields) {
+ body.appendChild(createFormFieldCard(name));
+ }
+ // Then annotations
+ for (const tracked of annotations) {
+ body.appendChild(createAnnotationCard(tracked));
+ }
+ // Then removed baseline annotations (crossed-out, revertable)
+ for (const def of removed) {
+ body.appendChild(createRemovedAnnotationCard(def));
+ }
+ },
+ );
+ }
+}
+
+function appendAccordionSection(
+ title: string,
+ sectionKey: string,
+ isOpen: boolean,
+ isCurrent: boolean,
+ populateBody: (body: HTMLElement) => void,
+): void {
+ const header = document.createElement("div");
+ header.className =
+ "annotation-section-header" +
+ (isCurrent ? " current-page" : "") +
+ (isOpen ? " open" : "");
+
+ const titleSpan = document.createElement("span");
+ titleSpan.textContent = title;
+ header.appendChild(titleSpan);
+
+ const chevron = document.createElement("span");
+ chevron.className = "annotation-section-chevron";
+ chevron.textContent = isOpen ? "โผ" : "โถ";
+ header.appendChild(chevron);
+
+ header.addEventListener("click", () => {
+ accordionUserInteracted = true;
+ const opening = panelState.openAccordionSection !== sectionKey;
+ panelState.openAccordionSection = opening ? sectionKey : null;
+ renderAnnotationPanel();
+ // Navigate to the page when expanding a page section
+ if (opening) {
+ const pageMatch = sectionKey.match(/^page-(\d+)$/);
+ if (pageMatch) {
+ deps.goToPage(Number(pageMatch[1]));
+ }
+ }
+ });
+
+ annotationsPanelListEl.appendChild(header);
+
+ const body = document.createElement("div");
+ body.className = "annotation-section-body" + (isOpen ? " open" : "");
+ if (isOpen) {
+ populateBody(body);
+ }
+ annotationsPanelListEl.appendChild(body);
+}
+
+const TRASH_SVG = ` `;
+const REVERT_SVG = ` `;
+
+function createAnnotationCard(tracked: TrackedAnnotation): HTMLElement {
+ const def = tracked.def;
+ const card = document.createElement("div");
+ card.className =
+ "annotation-card" + (selectedAnnotationIds.has(def.id) ? " selected" : "");
+ card.dataset.annotationId = def.id;
+
+ const row = document.createElement("div");
+ row.className = "annotation-card-row";
+
+ // Color swatch
+ const swatch = document.createElement("div");
+ swatch.className = "annotation-card-swatch";
+ swatch.style.background = getAnnotationColor(def);
+ row.appendChild(swatch);
+
+ // Type label
+ const typeLabel = document.createElement("span");
+ typeLabel.className = "annotation-card-type";
+ typeLabel.textContent = getAnnotationLabel(def);
+ row.appendChild(typeLabel);
+
+ // Preview text
+ const preview = getAnnotationPreview(def);
+ if (preview) {
+ const previewEl = document.createElement("span");
+ previewEl.className = "annotation-card-preview";
+ previewEl.textContent = preview;
+ row.appendChild(previewEl);
+ }
+
+ // Delete button
+ const deleteBtn = document.createElement("button");
+ deleteBtn.className = "annotation-card-delete";
+ deleteBtn.title = "Delete annotation";
+ deleteBtn.innerHTML = TRASH_SVG;
+ deleteBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ deps.removeAnnotation(def.id);
+ deps.persistAnnotations();
+ });
+ row.appendChild(deleteBtn);
+
+ // Expand chevron (only for annotations with content)
+ const hasContent = "content" in def && def.content;
+ if (hasContent) {
+ const expand = document.createElement("span");
+ expand.className = "annotation-card-expand";
+ expand.textContent = "โผ";
+ row.appendChild(expand);
+ }
+
+ card.appendChild(row);
+
+ // Expandable content area
+ if (hasContent) {
+ const contentEl = document.createElement("div");
+ contentEl.className = "annotation-card-content";
+ contentEl.textContent = (def as { content: string }).content;
+ card.appendChild(contentEl);
+ }
+
+ // Click handler: select + expand/collapse + navigate to page + pulse annotation
+ card.addEventListener("click", () => {
+ if (hasContent) {
+ card.classList.toggle("expanded");
+ }
+ if (def.page !== deps.state().currentPage) {
+ deps.goToPage(def.page);
+ setTimeout(() => {
+ deps.selectAnnotation(def.id);
+ pulseAnnotation(def.id);
+ }, 300);
+ } else {
+ deps.selectAnnotation(def.id);
+ pulseAnnotation(def.id);
+ if (tracked.elements.length > 0) {
+ tracked.elements[0].scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ }
+ }
+ });
+
+ // Hover handler: pulse annotation on PDF
+ card.addEventListener("mouseenter", () => {
+ if (def.page === deps.state().currentPage) {
+ pulseAnnotation(def.id);
+ }
+ });
+
+ // Double-click handler: send message to modify annotation
+ card.addEventListener("dblclick", (e) => {
+ e.stopPropagation();
+ // Select this annotation + update model context before sending message
+ deps.selectAnnotation(def.id);
+ const label = getAnnotationLabel(def);
+ const previewText = getAnnotationPreview(def);
+ const desc = previewText ? `${label}: ${previewText}` : label;
+ void deps
+ .sendMessage({
+ role: "user",
+ content: [{ type: "text", text: `update ${desc}: ` }],
+ })
+ .catch(log.error);
+ });
+
+ return card;
+}
+
+/**
+ * Card for a baseline annotation the user deleted: crossed-out, no select/
+ * navigate (it has no DOM on the page anymore), revert button puts it back
+ * into `annotationMap` so it renders again and save leaves it in the file.
+ */
+function createRemovedAnnotationCard(def: PdfAnnotationDef): HTMLElement {
+ const card = document.createElement("div");
+ card.className = "annotation-card annotation-card-cleared";
+ card.dataset.annotationId = def.id;
+
+ const row = document.createElement("div");
+ row.className = "annotation-card-row";
+
+ const swatch = document.createElement("div");
+ swatch.className = "annotation-card-swatch annotation-card-swatch-cleared";
+ swatch.innerHTML = ` `;
+ row.appendChild(swatch);
+
+ const typeLabel = document.createElement("span");
+ typeLabel.className = "annotation-card-type";
+ typeLabel.textContent = getAnnotationLabel(def);
+ row.appendChild(typeLabel);
+
+ const preview = getAnnotationPreview(def);
+ if (preview) {
+ const previewEl = document.createElement("span");
+ previewEl.className = "annotation-card-preview";
+ previewEl.textContent = preview;
+ row.appendChild(previewEl);
+ }
+
+ const revertBtn = document.createElement("button");
+ revertBtn.className = "annotation-card-delete";
+ revertBtn.title = "Restore annotation from file";
+ revertBtn.innerHTML = REVERT_SVG;
+ revertBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ annotationMap.set(def.id, { def: { ...def }, elements: [] });
+ updateAnnotationsBadge();
+ renderAnnotationPanel();
+ deps.renderPage();
+ deps.persistAnnotations();
+ });
+ row.appendChild(revertBtn);
+
+ card.appendChild(row);
+ return card;
+}
+
+/** Revert one field to its PDF-stored baseline value. */
+function revertFieldToBaseline(name: string): void {
+ const base = pdfBaselineFormValues.get(name);
+ if (base === undefined) return;
+ formFieldValues.set(name, base);
+ // Remove our storage override โ widget falls back to PDF's /V = baseline
+ const { pdfDocument } = deps.state();
+ if (pdfDocument) {
+ const ids = fieldNameToIds.get(name);
+ if (ids) for (const id of ids) pdfDocument.annotationStorage.remove(id);
+ }
+}
+
+function createFormFieldCard(name: string): HTMLElement {
+ const state = fieldState(name);
+ const value = formFieldValues.get(name);
+ const baseValue = pdfBaselineFormValues.get(name);
+
+ const card = document.createElement("div");
+ card.className = "annotation-card";
+ if (state === "cleared") card.classList.add("annotation-card-cleared");
+
+ const row = document.createElement("div");
+ row.className = "annotation-card-row";
+
+ // Swatch: solid blue normally; crossed-out for cleared baseline fields
+ const swatch = document.createElement("div");
+ swatch.className = "annotation-card-swatch";
+ if (state === "cleared") {
+ swatch.classList.add("annotation-card-swatch-cleared");
+ swatch.innerHTML = ` `;
+ } else {
+ swatch.style.background = "#4a90d9";
+ }
+ // Subtle modified marker
+ if (state === "modified") swatch.title = "Modified from file";
+ row.appendChild(swatch);
+
+ // Field label
+ const nameEl = document.createElement("span");
+ nameEl.className = "annotation-card-type";
+ nameEl.textContent = getFormFieldLabel(name);
+ row.appendChild(nameEl);
+
+ // Value preview: show current, or struck-out baseline when cleared
+ const shown = state === "cleared" ? baseValue : value;
+ const displayValue =
+ typeof shown === "boolean" ? (shown ? "checked" : "unchecked") : shown;
+ if (displayValue) {
+ const valueEl = document.createElement("span");
+ valueEl.className = "annotation-card-preview";
+ valueEl.textContent = displayValue;
+ row.appendChild(valueEl);
+ }
+
+ // Action button: revert for modified/cleared baseline fields, trash otherwise
+ const isRevertable = state === "modified" || state === "cleared";
+ const actionBtn = document.createElement("button");
+ actionBtn.className = "annotation-card-delete";
+ actionBtn.title = isRevertable
+ ? "Revert to value stored in file"
+ : "Clear field";
+ actionBtn.innerHTML = isRevertable ? REVERT_SVG : TRASH_SVG;
+ actionBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ if (isRevertable) {
+ revertFieldToBaseline(name);
+ } else {
+ formFieldValues.delete(name);
+ clearFieldInStorage(name);
+ }
+ updateAnnotationsBadge();
+ renderAnnotationPanel();
+ deps.renderPage();
+ deps.persistAnnotations();
+ });
+ row.appendChild(actionBtn);
+
+ // Click handler: navigate to page and focus form input
+ card.addEventListener("click", () => {
+ const fieldPage = fieldNameToPage.get(name) ?? 1;
+ // Auto-expand the page's accordion section
+ panelState.openAccordionSection = `page-${fieldPage}`;
+ const focusField = () => {
+ const input = formLayerEl.querySelector(
+ `[name="${CSS.escape(name)}"]`,
+ ) as HTMLElement | null;
+ if (input) {
+ input.focus();
+ input.scrollIntoView({ behavior: "smooth", block: "center" });
+ }
+ };
+ if (fieldPage !== deps.state().currentPage) {
+ deps.goToPage(fieldPage);
+ setTimeout(focusField, 300);
+ } else {
+ focusField();
+ }
+ });
+
+ // Double-click handler: send message to fill field
+ card.addEventListener("dblclick", (e) => {
+ e.stopPropagation();
+ // Focus field + update model context before sending message
+ deps.setFocusedField(name);
+ deps.updatePageContext();
+ const fieldLabel = getFormFieldLabel(name);
+ void deps
+ .sendMessage({
+ role: "user",
+ content: [{ type: "text", text: `update ${fieldLabel}: ` }],
+ })
+ .catch(log.error);
+ });
+
+ card.appendChild(row);
+ return card;
+}
+
+function pulseAnnotation(id: string): void {
+ const tracked = annotationMap.get(id);
+ if (!tracked) return;
+ for (const el of tracked.elements) {
+ el.classList.remove("annotation-pulse");
+ // Force reflow to restart animation
+ void el.offsetWidth;
+ el.classList.add("annotation-pulse");
+ el.addEventListener(
+ "animationend",
+ () => {
+ el.classList.remove("annotation-pulse");
+ },
+ { once: true },
+ );
+ }
+}
+
+/** Toggle the `.selected` class on every card to match selectedAnnotationIds. */
+export function syncSidebarSelection(): void {
+ for (const card of annotationsPanelListEl.querySelectorAll(
+ ".annotation-card",
+ )) {
+ const cardId = (card as HTMLElement).dataset.annotationId;
+ card.classList.toggle(
+ "selected",
+ !!cardId && selectedAnnotationIds.has(cardId),
+ );
+ }
+}
+
+// =============================================================================
+// Reset / Clear
+// =============================================================================
+
+/** Remove the DOM elements backing every annotation and clear the map. */
+function clearAnnotationMap(): void {
+ for (const [, tracked] of annotationMap) {
+ for (const el of tracked.elements) el.remove();
+ }
+ annotationMap.clear();
+}
+
+/**
+ * Push a field's defaultValue (/DV) into annotationStorage so the widget
+ * renders cleared. annotationStorage.remove() only drops our override โ
+ * the widget reverts to the PDF's /V (the stored value), not /DV.
+ *
+ * Widget IDs come from page.getAnnotations(); field metadata (types,
+ * defaultValue) comes from getFieldObjects(). We match them by field name.
+ */
+function clearFieldInStorage(name: string): void {
+ const { pdfDocument, cachedFieldObjects } = deps.state();
+ if (!pdfDocument) return;
+ const ids = fieldNameToIds.get(name);
+ if (!ids) return;
+ const storage = pdfDocument.annotationStorage;
+ const meta = cachedFieldObjects?.[name];
+ // defaultValue is per-field, not per-widget โ take from first non-parent entry
+ const dv =
+ meta?.find((f) => f.defaultValue != null)?.defaultValue ??
+ meta?.[0]?.defaultValue ??
+ "";
+ const type = meta?.find((f) => f.type)?.type;
+ // Radio: per-widget BOOLEANS, never a string. pdf.js's
+ // RadioButtonWidgetAnnotation render() has inverted string coercion (see
+ // setFieldInStorage), so writing the same string to every widget checks
+ // the wrong one. {value:false} on all = nothing selected.
+ if (type === "radiobutton") {
+ for (const id of ids) storage.setValue(id, { value: false });
+ return;
+ }
+ const clearValue = type === "checkbox" ? (dv ?? "Off") : (dv ?? "");
+ for (const id of ids) storage.setValue(id, { value: clearValue });
+}
+
+/**
+ * Revert to what's in the PDF file: restore baseline annotations, restore
+ * baseline form values, discard all user edits. Result: diff is empty, clean.
+ *
+ * Form fields: remove ALL storage overrides โ every field reverts to the
+ * PDF's /V (which IS baseline). We can't skip baseline-named fields: if the
+ * user edited one, our override is in storage under that name, and skipping
+ * it leaves the widget showing the stale edit.
+ */
+function resetToBaseline(): void {
+ clearAnnotationMap();
+ const { pdfDocument, pdfBaselineAnnotations } = deps.state();
+ for (const def of pdfBaselineAnnotations) {
+ annotationMap.set(def.id, { def: { ...def }, elements: [] });
+ }
+
+ if (pdfDocument) {
+ const storage = pdfDocument.annotationStorage;
+ for (const name of new Set([
+ ...formFieldValues.keys(),
+ ...pdfBaselineFormValues.keys(),
+ ])) {
+ const ids = fieldNameToIds.get(name);
+ if (ids) for (const id of ids) storage.remove(id);
+ }
+ }
+ formFieldValues.clear();
+ for (const [name, value] of pdfBaselineFormValues) {
+ formFieldValues.set(name, value);
+ }
+
+ undoStack.length = 0;
+ redoStack.length = 0;
+ selectedAnnotationIds.clear();
+
+ updateAnnotationsBadge();
+ deps.persistAnnotations(); // diff is now empty โ setDirty(false)
+ deps.renderPage();
+ renderAnnotationPanel();
+}
+
+/**
+ * Remove everything, including annotations and form values that came from
+ * the PDF file. Result: diff is non-empty (baseline items are "removed"),
+ * dirty โ saving writes a stripped PDF.
+ *
+ * Form fields: annotationStorage.remove() only drops our override, so the
+ * widget reverts to the PDF's stored /V. To actually CLEAR we must push
+ * each field's defaultValue (/DV) โ which is what the PDF's own Reset
+ * button would do.
+ *
+ * Note: baseline annotations are still baked into the canvas appearance
+ * stream โ we can only remove them from our overlay and the panel. Saving
+ * will omit them from the output (getAnnotatedPdfBytes skips baseline).
+ */
+function clearAllItems(): void {
+ clearAnnotationMap();
+
+ for (const name of new Set([
+ ...formFieldValues.keys(),
+ ...pdfBaselineFormValues.keys(),
+ ])) {
+ clearFieldInStorage(name);
+ }
+ formFieldValues.clear();
+
+ undoStack.length = 0;
+ redoStack.length = 0;
+ selectedAnnotationIds.clear();
+
+ updateAnnotationsBadge();
+ deps.persistAnnotations();
+ deps.renderPage();
+ renderAnnotationPanel();
+}
+
+// =============================================================================
+// Init
+// =============================================================================
+
+export function initAnnotationPanel(panelDeps: PanelDeps): void {
+ deps = panelDeps;
+
+ // Restore user preference
+ try {
+ const pref = localStorage.getItem("pdf-annotation-panel");
+ if (pref === "open") annotationPanelUserPref = true;
+ else if (pref === "closed") annotationPanelUserPref = false;
+ } catch {
+ /* ignore */
+ }
+
+ // Restore saved panel width
+ try {
+ const savedWidth = localStorage.getItem("pdf-annotation-panel-width");
+ if (savedWidth) {
+ const w = parseInt(savedWidth, 10);
+ if (w >= 120) {
+ annotationsPanelEl.style.width = `${w}px`;
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+
+ // Resize handle โ direction-aware based on anchorage
+ const resizeHandle = document.getElementById("annotation-panel-resize")!;
+ resizeHandle.addEventListener("mousedown", (e) => {
+ e.preventDefault();
+ resizeHandle.classList.add("dragging");
+ const startX = e.clientX;
+ const startWidth = annotationsPanelEl.offsetWidth;
+ const isRight = floatingPanelCorner.includes("right");
+
+ const onMouseMove = (ev: MouseEvent) => {
+ const dx = ev.clientX - startX;
+ // If panel is on the right, dragging left (negative dx) increases width
+ // If panel is on the left, dragging right (positive dx) increases width
+ const newWidth = Math.max(120, startWidth + (isRight ? -dx : dx));
+ annotationsPanelEl.style.width = `${newWidth}px`;
+ };
+ const onMouseUp = () => {
+ resizeHandle.classList.remove("dragging");
+ document.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("mouseup", onMouseUp);
+ try {
+ localStorage.setItem(
+ "pdf-annotation-panel-width",
+ String(annotationsPanelEl.offsetWidth),
+ );
+ } catch {
+ /* ignore */
+ }
+ deps.requestFitToContent();
+ };
+ document.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("mouseup", onMouseUp);
+ });
+
+ // Floating panel drag-to-reposition
+ const panelHeader = annotationsPanelEl.querySelector(
+ ".annotation-panel-header",
+ ) as HTMLElement;
+ if (panelHeader) {
+ panelHeader.addEventListener("mousedown", (e) => {
+ if (!annotationsPanelEl.classList.contains("floating")) return;
+ // Ignore clicks on buttons within header
+ if ((e.target as HTMLElement).closest("button")) return;
+ e.preventDefault();
+ const startX = e.clientX;
+ const startY = e.clientY;
+ const container = annotationsPanelEl.parentElement!;
+ const containerRect = container.getBoundingClientRect();
+ let moved = false;
+
+ // Temporarily position absolutely during drag
+ const panelRect = annotationsPanelEl.getBoundingClientRect();
+ let curLeft = panelRect.left - containerRect.left;
+ let curTop = panelRect.top - containerRect.top;
+
+ // Switch to left/top positioning for free drag
+ annotationsPanelEl.style.right = "";
+ annotationsPanelEl.style.bottom = "";
+ annotationsPanelEl.style.left = `${curLeft}px`;
+ annotationsPanelEl.style.top = `${curTop}px`;
+ annotationsPanelEl.style.transition = "none";
+ annotationsPanelEl.classList.add("dragging");
+
+ const onMouseMove = (ev: MouseEvent) => {
+ const dx = ev.clientX - startX;
+ const dy = ev.clientY - startY;
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true;
+ const newLeft = Math.max(
+ 0,
+ Math.min(
+ curLeft + dx,
+ containerRect.width - annotationsPanelEl.offsetWidth,
+ ),
+ );
+ const newTop = Math.max(
+ 0,
+ Math.min(
+ curTop + dy,
+ containerRect.height - annotationsPanelEl.offsetHeight,
+ ),
+ );
+ annotationsPanelEl.style.left = `${newLeft}px`;
+ annotationsPanelEl.style.top = `${newTop}px`;
+ };
+
+ const onMouseUp = () => {
+ document.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("mouseup", onMouseUp);
+ annotationsPanelEl.classList.remove("dragging");
+ annotationsPanelEl.style.transition = "";
+
+ if (!moved) return;
+
+ // Snap to nearest corner (magnetic anchor)
+ const finalRect = annotationsPanelEl.getBoundingClientRect();
+ const cx = finalRect.left + finalRect.width / 2 - containerRect.left;
+ const cy = finalRect.top + finalRect.height / 2 - containerRect.top;
+ const midX = containerRect.width / 2;
+ const midY = containerRect.height / 2;
+
+ const isRight = cx > midX;
+ const isBottom = cy > midY;
+ floatingPanelCorner = isBottom
+ ? isRight
+ ? "bottom-right"
+ : "bottom-left"
+ : isRight
+ ? "top-right"
+ : "top-left";
+
+ applyFloatingPanelPosition();
+ try {
+ localStorage.setItem("pdf-panel-corner", floatingPanelCorner);
+ } catch {
+ /* ignore */
+ }
+ };
+
+ document.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("mouseup", onMouseUp);
+ });
+ }
+
+ // Restore saved corner
+ try {
+ const saved = localStorage.getItem("pdf-panel-corner");
+ if (
+ saved &&
+ ["top-right", "top-left", "bottom-right", "bottom-left"].includes(saved)
+ ) {
+ floatingPanelCorner = saved as PanelCorner;
+ }
+ } catch {
+ /* ignore */
+ }
+
+ // Toggle button
+ annotationsBtn.addEventListener("click", toggleAnnotationPanel);
+ annotationsPanelCloseBtn.addEventListener("click", toggleAnnotationPanel);
+ annotationsPanelResetBtn.addEventListener("click", resetToBaseline);
+ annotationsPanelClearAllBtn.addEventListener("click", clearAllItems);
+
+ updateAnnotationsBadge();
+}
diff --git a/examples/pdf-server/src/commands.ts b/examples/pdf-server/src/commands.ts
new file mode 100644
index 0000000..6d27edf
--- /dev/null
+++ b/examples/pdf-server/src/commands.ts
@@ -0,0 +1,70 @@
+/**
+ * PdfCommand โ the wire protocol between server and viewer.
+ *
+ * The server enqueues these via the `interact` tool; the viewer polls
+ * `poll_pdf_commands` and receives them as `structuredContent.commands`.
+ *
+ * This file is the single source of truth for the command shape. Both
+ * `server.ts` (which enqueues) and `mcp-app.ts` (which consumes) import
+ * from here โ a new command variant must be added exactly once.
+ *
+ * Uses `import type` so neither side pulls pdf-lib into its bundle.
+ */
+
+import type { PdfAnnotationDef } from "./pdf-annotations.js";
+
+/** Single form field assignment, as sent by `fill_form`. */
+export interface FormFieldFill {
+ name: string;
+ value: string | boolean;
+}
+
+/**
+ * Partial annotation update: `id` + `type` pin the target, everything
+ * else is optional. Server validates shape; viewer merges into existing.
+ */
+export type PdfAnnotationPatch = Partial & {
+ id: string;
+ type: PdfAnnotationDef["type"];
+};
+
+/** Page range for text/screenshot extraction. Omitted bound = open-ended. */
+export interface PageInterval {
+ start?: number;
+ end?: number;
+}
+
+/**
+ * Commands the server can send to the viewer via the poll queue.
+ * Adding a variant here means adding a `case` in both the server's
+ * `interact` handler (to enqueue) and the viewer's `processCommands`
+ * (to execute).
+ */
+export type PdfCommand =
+ | { type: "navigate"; page: number }
+ | { type: "search"; query: string }
+ | { type: "find"; query: string }
+ | { type: "search_navigate"; matchIndex: number }
+ | { type: "zoom"; scale: number }
+ | { type: "add_annotations"; annotations: PdfAnnotationDef[] }
+ | { type: "update_annotations"; annotations: PdfAnnotationPatch[] }
+ | { type: "remove_annotations"; ids: string[] }
+ | {
+ type: "highlight_text";
+ id: string;
+ query: string;
+ page?: number;
+ color?: string;
+ content?: string;
+ }
+ | { type: "fill_form"; fields: FormFieldFill[] }
+ | {
+ type: "get_pages";
+ requestId: string;
+ intervals: PageInterval[];
+ getText: boolean;
+ getScreenshots: boolean;
+ }
+ | { type: "save_as"; requestId: string }
+ | { type: "get_viewer_state"; requestId: string }
+ | { type: "file_changed"; mtimeMs: number };
diff --git a/examples/pdf-server/src/mcp-app.css b/examples/pdf-server/src/mcp-app.css
index 008b22a..a87dbcd 100644
--- a/examples/pdf-server/src/mcp-app.css
+++ b/examples/pdf-server/src/mcp-app.css
@@ -13,10 +13,7 @@
--text200: light-dark(#999999, #888888);
/* Shadows */
- --shadow-page: light-dark(
- 0 2px 8px rgba(0, 0, 0, 0.15),
- 0 2px 8px rgba(0, 0, 0, 0.4)
- );
+ --shadow-page-color: light-dark(rgba(0 0 0 / 0.15), rgba(0 0 0 / 0.4));
--selection-bg: light-dark(rgba(0, 0, 255, 0.3), rgba(100, 150, 255, 0.4));
}
@@ -103,13 +100,14 @@ body {
display: flex;
align-items: center;
justify-content: space-between;
- padding: 0.5rem 1rem;
+ padding: 0.5rem 0.5rem;
background: var(--bg000);
border-bottom: 1px solid var(--bg200);
flex-shrink: 0;
- gap: 0.5rem;
- height: 48px;
+ gap: 0.25rem 0.5rem;
+ min-height: 48px;
box-sizing: border-box;
+ flex-wrap: wrap;
}
.toolbar-left {
@@ -222,17 +220,24 @@ body {
/* Single Page Canvas Container */
.canvas-container {
flex: 1;
- overflow: visible;
+ overflow-x: auto; /* Allow horizontal scrolling when zoomed in inline mode */
+ overflow-y: visible;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 1rem;
background: var(--bg200);
+ /* JS owns pinch (inline pinch-in โ fullscreen). pan-x/pan-y keeps native
+ * scrolling; the fullscreen rule below repeats this with a comment on the
+ * iOS-Safari preventDefault backstop. */
+ touch-action: pan-x pan-y;
}
.page-wrapper {
position: relative;
- box-shadow: var(--shadow-page);
+ box-shadow:
+ 0 1px 4px var(--shadow-page-color),
+ 0 4px 16px var(--shadow-page-color);
background: white;
}
@@ -261,7 +266,6 @@ body {
--min-font-size-inv: calc(1 / var(--min-font-size, 1));
}
-
.text-layer :is(span, br) {
color: transparent;
position: absolute;
@@ -306,16 +310,48 @@ body {
overflow: hidden; /* No scrolling on main - only canvas-container scrolls */
border-radius: 0;
border: none;
- padding: 0 !important; /* Ignore safe area insets in fullscreen */
+ padding: 0 !important; /* Background extends edge-to-edge */
+}
+
+.main.fullscreen .toolbar {
+ /* Hosts wrap fullscreen in their own header (title + close), which already
+ * clears the top safe-area โ so no --safe-top here, only sides. */
+ padding: 0.25rem calc(0.5rem + var(--safe-right, 0px)) 0.25rem
+ calc(0.5rem + var(--safe-left, 0px));
+ min-height: 40px;
+ /* Inline can wrap (narrow chat bubble); fullscreen has the width, and
+ * wrapping would double the bar height + desync the search-bar offset. */
+ flex-wrap: nowrap;
+}
+
+.main.fullscreen .search-bar {
+ /* -1px overlaps the toolbar border so the dropdown looks attached. */
+ top: 39px;
+ right: calc(var(--safe-right, 0px) - 1px);
}
.main.fullscreen .viewer {
min-height: 0; /* Allow flex item to shrink below content size */
}
+.main.fullscreen .viewer-body {
+ min-height: 0;
+}
+
.main.fullscreen .canvas-container {
min-height: 0; /* Allow flex item to shrink below content size */
overflow: auto; /* Scroll within the document area only */
+ /* Let JS own pinch-zoom (we re-render the PDF, not the viewport).
+ * pan-x pan-y keeps native scrolling/panning working. iOS Safari only
+ * partially honours this โ preventDefault on touchmove backs it up. */
+ touch-action: pan-x pan-y;
+ /* Keep the page clear of host overlays (chat input, home indicator).
+ * computeFitScale subtracts padding, so fit-to-page lands above these.
+ * The container background still extends edge-to-edge (.main.fullscreen
+ * has padding:0). Top inset is on the toolbar; the rest land here. */
+ padding-bottom: calc(1rem + var(--safe-bottom, 0px));
+ padding-left: calc(1rem + var(--safe-left, 0px));
+ padding-right: calc(1rem + var(--safe-right, 0px));
}
/* Search Button */
@@ -408,6 +444,376 @@ body {
cursor: not-allowed;
}
+/* Annotation Layer */
+.annotation-layer {
+ position: absolute;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ pointer-events: none;
+ z-index: 3; /* above text-layer (z-index: 2) so notes are clickable */
+}
+
+.annotation-highlight {
+ position: absolute;
+ background: rgba(255, 255, 0, 0.35);
+ mix-blend-mode: multiply;
+ border-radius: 1px;
+ pointer-events: auto;
+ cursor: pointer;
+}
+
+.annotation-underline {
+ position: absolute;
+ border-bottom: 2px solid #ff0000;
+ box-sizing: border-box;
+ pointer-events: auto;
+ cursor: pointer;
+}
+
+.annotation-strikethrough {
+ position: absolute;
+ box-sizing: border-box;
+ pointer-events: auto;
+ cursor: pointer;
+}
+.annotation-strikethrough::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 50%;
+ border-top: 2px solid #ff0000;
+}
+
+.annotation-note {
+ position: absolute;
+ width: 20px;
+ height: 20px;
+ cursor: pointer;
+ pointer-events: auto;
+}
+.annotation-note::after {
+ content: "";
+ display: block;
+ width: 16px;
+ height: 16px;
+ margin: 2px;
+ background: currentColor;
+ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='black'%3E%3Cpath d='M3 1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V5l-4-4H3zm7 1.5L13.5 6H11a1 1 0 0 1-1-1V2.5zM5 8.5h6v1H5v-1zm0 2.5h4v1H5v-1z'/%3E%3C/svg%3E") no-repeat center / contain;
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='black'%3E%3Cpath d='M3 1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V5l-4-4H3zm7 1.5L13.5 6H11a1 1 0 0 1-1-1V2.5zM5 8.5h6v1H5v-1zm0 2.5h4v1H5v-1z'/%3E%3C/svg%3E") no-repeat center / contain;
+}
+.annotation-note .annotation-tooltip {
+ display: none;
+ position: absolute;
+ bottom: 100%;
+ left: 0;
+ background: var(--bg000, #fff);
+ color: var(--text000, #000);
+ border: 1px solid var(--bg200, #ccc);
+ border-radius: 4px;
+ padding: 4px 8px;
+ font-size: 0.8rem;
+ white-space: pre-wrap;
+ max-width: 200px;
+ z-index: 100; /* Must float above all sibling annotation elements */
+ pointer-events: none;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
+}
+.annotation-note:hover .annotation-tooltip {
+ display: block;
+}
+
+/* When hovering a note, raise the entire annotation layer above the form
+ layer so the tooltip isn't clipped by the z-index:4 form overlay. */
+.annotation-layer:has(.annotation-note:hover) {
+ z-index: 5;
+}
+
+.annotation-rectangle {
+ position: absolute;
+ border: 2px solid #0066cc;
+ box-sizing: border-box;
+ pointer-events: auto;
+ cursor: grab;
+ user-select: none;
+}
+
+.annotation-circle {
+ position: absolute;
+ border: 2px solid #0066cc;
+ border-radius: 50%;
+ box-sizing: border-box;
+ pointer-events: auto;
+ cursor: grab;
+ user-select: none;
+}
+
+.annotation-line {
+ position: absolute;
+ height: 0;
+ border-top: 2px solid #333;
+ pointer-events: auto;
+ cursor: grab;
+ user-select: none;
+}
+
+.annotation-freetext {
+ position: absolute;
+ font-family: Helvetica, Arial, sans-serif;
+ white-space: pre-wrap;
+ pointer-events: auto;
+ cursor: grab;
+ user-select: none;
+}
+
+.annotation-stamp {
+ position: absolute;
+ font-family: Helvetica, Arial, sans-serif;
+ font-weight: bold;
+ font-size: 24px;
+ border: 3px solid currentColor;
+ padding: 4px 12px;
+ opacity: 0.6;
+ text-transform: uppercase;
+ white-space: nowrap;
+ pointer-events: auto;
+ cursor: grab;
+ user-select: none;
+}
+
+.annotation-image {
+ position: absolute;
+ pointer-events: auto;
+ cursor: grab;
+ user-select: none;
+}
+
+/* Annotation imported verbatim from the PDF (Stamp/Ink/etc. via
+ * annotationCanvasMap). The body is the rasterized appearance canvas;
+ * if pdf.js didn't divert it (no hasOwnCanvas), the box stays transparent
+ * over the main-canvas pixel and just captures clicks. */
+.annotation-imported {
+ position: absolute;
+ pointer-events: auto;
+ cursor: grab;
+ user-select: none;
+}
+.annotation-imported:hover {
+ outline: 1px dashed var(--accent, #2563eb);
+ outline-offset: 1px;
+}
+
+/* Selection visuals */
+.annotation-selected {
+ outline: 2px solid var(--accent, #2563eb);
+ outline-offset: 2px;
+ z-index: 10;
+}
+
+.annotation-handle {
+ position: absolute;
+ width: 8px;
+ height: 8px;
+ background: white;
+ border: 2px solid var(--accent, #2563eb);
+ border-radius: 50%;
+ z-index: 11;
+ pointer-events: auto;
+}
+.annotation-handle.nw { top: -5px; left: -5px; cursor: nwse-resize; }
+.annotation-handle.ne { top: -5px; right: -5px; cursor: nesw-resize; }
+.annotation-handle.sw { bottom: -5px; left: -5px; cursor: nesw-resize; }
+.annotation-handle.se { bottom: -5px; right: -5px; cursor: nwse-resize; }
+
+.annotation-handle-rotate {
+ position: absolute;
+ top: -20px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 10px;
+ height: 10px;
+ background: white;
+ border: 2px solid var(--accent, #2563eb);
+ border-radius: 50%;
+ z-index: 11;
+ pointer-events: auto;
+ cursor: grab;
+}
+
+.annotation-card.selected {
+ background: var(--accent-bg, rgba(37, 99, 235, 0.08));
+ border-left-color: var(--accent, #2563eb);
+}
+
+.annotation-dragging {
+ cursor: grabbing !important;
+}
+
+@media (prefers-color-scheme: dark) {
+ .annotation-highlight {
+ background: rgba(255, 255, 0, 0.3);
+ mix-blend-mode: screen;
+ }
+}
+
+/* Save / Download Buttons */
+.save-btn,
+.download-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border: 1px solid var(--bg200);
+ border-radius: 4px;
+ background: var(--bg000);
+ color: var(--text000);
+ cursor: pointer;
+ font-size: 1rem;
+ transition: all 0.15s ease;
+}
+
+.save-btn:hover:not(:disabled),
+.download-btn:hover:not(:disabled) {
+ background: var(--bg100);
+ border-color: var(--bg300);
+}
+
+.save-btn:disabled,
+.download-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+/*
+ * Confirmation Dialog
+ *
+ * Styled to match the host's native dialogs (e.g. the downloadFile prompt).
+ * Uses host-provided CSS variables (set via applyHostStyleVariables) with
+ * local fallbacks so it still looks reasonable in standalone dev.
+ */
+.confirm-dialog {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: light-dark(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.6));
+ font-family: var(--font-sans, inherit);
+}
+
+.confirm-box {
+ background: var(--color-background-primary, var(--bg000));
+ border-radius: var(--border-radius-xl, 20px);
+ padding: 1.75rem;
+ min-width: 360px;
+ max-width: 520px;
+ box-shadow: var(--shadow-lg, 0 20px 50px rgba(0, 0, 0, 0.3));
+}
+
+.confirm-title {
+ font-weight: var(--font-weight-bold, 700);
+ font-size: var(--font-heading-md-size, 1.5rem);
+ line-height: var(--font-heading-md-line-height, 1.25);
+ margin-bottom: 0.5rem;
+ color: var(--color-text-primary, var(--text000));
+}
+
+.confirm-body {
+ font-size: var(--font-text-md-size, 1rem);
+ line-height: var(--font-text-md-line-height, 1.5);
+ color: var(--color-text-secondary, var(--text100));
+ margin-bottom: 1.5rem;
+ word-break: break-word;
+}
+
+/* Optional monospace detail box (filename, path, etc.) */
+.confirm-detail {
+ font-family: var(--font-mono, ui-monospace, monospace);
+ font-size: var(--font-text-sm-size, 0.9rem);
+ padding: 0.75rem 1rem;
+ margin-bottom: 1.5rem;
+ border: var(--border-width-regular, 1px) solid
+ var(--color-border-secondary, var(--bg200));
+ border-radius: var(--border-radius-md, 8px);
+ background: var(--color-background-secondary, var(--bg100));
+ color: var(--color-text-primary, var(--text000));
+ word-break: break-all;
+}
+
+.confirm-detail:empty {
+ display: none;
+}
+
+.confirm-buttons {
+ display: flex;
+ gap: 0.75rem;
+ justify-content: flex-end;
+}
+
+.confirm-btn {
+ padding: 0.625rem 1.25rem;
+ border: var(--border-width-regular, 1px) solid
+ var(--color-border-primary, var(--bg200));
+ border-radius: var(--border-radius-md, 8px);
+ background: var(--color-background-primary, var(--bg000));
+ color: var(--color-text-primary, var(--text000));
+ cursor: pointer;
+ font: inherit;
+ font-size: var(--font-text-md-size, 0.95rem);
+ font-weight: var(--font-weight-medium, 500);
+}
+
+.confirm-btn:hover {
+ background: var(--color-background-secondary, var(--bg100));
+}
+
+.confirm-btn-primary {
+ /* Host's native primary buttons use inverse colors (dark bg, light text) */
+ background: var(--color-background-inverse, var(--text000));
+ border-color: var(--color-background-inverse, var(--text000));
+ color: var(--color-text-inverse, var(--bg000));
+}
+
+.confirm-btn-primary:hover {
+ /* .confirm-btn:hover above would otherwise reset background */
+ background: var(--color-background-inverse, var(--text000));
+ filter: brightness(1.15);
+}
+
+/* Form Layer (PDF.js AnnotationLayer for interactive form widgets) */
+#form-layer {
+ position: absolute;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 4; /* above annotation-layer (z-index: 3) so form inputs are clickable */
+}
+
+/*
+ * PDF.js AnnotationLayer renders interactive form inputs on top of the canvas.
+ * When the widget is transparent the canvas's static appearance bleeds through,
+ * causing double-text (the widget font never matches the PDF's font metrics).
+ * Fix: force widgets opaque. PDF.js also reads the annotation's /BC entry and
+ * writes it as an inline `background-color` on the element (typically
+ * `transparent` for unstyled forms) โ inline style beats any selector, hence
+ * !important.
+ */
+#form-layer .textWidgetAnnotation :is(input, textarea),
+#form-layer .choiceWidgetAnnotation select {
+ background-image: none !important;
+ background-color: light-dark(#fff, #2a2a2a) !important;
+}
+
+#form-layer select option:checked {
+ background: light-dark(#d0e0ff, #335);
+}
+
+
/* Highlight Layer */
.highlight-layer {
position: absolute;
@@ -442,6 +848,346 @@ body {
}
}
+/* Viewer Body (flex row for canvas + panel) */
+.viewer-body {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ overflow: visible;
+ position: relative; /* Anchor for floating annotation panel */
+}
+
+/* Annotations Toolbar Button */
+.annotations-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border: 1px solid var(--bg200);
+ border-radius: 4px;
+ background: var(--bg000);
+ color: var(--text000);
+ cursor: pointer;
+ font-size: 1rem;
+ transition: all 0.15s ease;
+ position: relative;
+}
+.annotations-btn:hover {
+ background: var(--bg100);
+ border-color: var(--bg300);
+}
+.annotations-btn.active {
+ background: var(--bg200);
+ border-color: var(--bg300);
+}
+
+.annotations-badge {
+ position: absolute;
+ top: -4px;
+ right: -4px;
+ min-width: 16px;
+ height: 16px;
+ background: var(--accent, #2563eb);
+ color: #fff;
+ font-size: 0.65rem;
+ font-weight: 600;
+ border-radius: 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 3px;
+ line-height: 1;
+}
+
+/* Annotation Side Panel */
+.annotation-panel {
+ width: 500px;
+ min-width: 180px;
+ max-width: 50vw;
+ background: var(--bg000);
+ border-left: 1px solid var(--bg200);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ position: relative;
+}
+
+/* Resize handle on left edge */
+.annotation-panel-resize {
+ position: absolute;
+ left: -3px;
+ top: 0;
+ bottom: 0;
+ width: 6px;
+ cursor: col-resize;
+ z-index: 10;
+}
+.annotation-panel-resize:hover,
+.annotation-panel-resize.dragging {
+ background: var(--text100);
+ opacity: 0.3;
+}
+
+.annotation-panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.5rem 0.75rem;
+ border-bottom: 1px solid var(--bg200);
+ flex-shrink: 0;
+}
+
+.annotation-panel-title {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: var(--text000);
+}
+
+.annotation-panel-header-actions {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.annotation-panel-reset,
+.annotation-panel-clear-all {
+ border: none;
+ background: transparent;
+ color: var(--text100);
+ cursor: pointer;
+ border-radius: 4px;
+ padding: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.annotation-panel-reset:hover {
+ color: var(--text000);
+ background: var(--bg100);
+}
+.annotation-panel-clear-all:hover {
+ color: #e74c3c;
+ background: var(--bg100);
+}
+.annotation-panel-reset:disabled,
+.annotation-panel-clear-all:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.annotation-panel-close {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border: none;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--text100);
+ cursor: pointer;
+ font-size: 0.8rem;
+ transition: all 0.15s ease;
+}
+.annotation-panel-close:hover {
+ background: var(--bg100);
+ color: var(--text000);
+}
+
+.annotation-panel-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.25rem 0;
+}
+
+/* Floating annotation panel (used in both inline and fullscreen) */
+.annotation-panel.floating {
+ position: absolute;
+ z-index: 20;
+ border: 1px solid var(--bg200);
+ border-radius: 6px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ max-height: 60%;
+ width: 135px;
+ transition: top 0.15s ease, left 0.15s ease, right 0.15s ease, bottom 0.15s ease;
+}
+.annotation-panel.floating .annotation-panel-header {
+ cursor: grab;
+}
+.annotation-panel.floating.dragging .annotation-panel-header {
+ cursor: grabbing;
+}
+
+/* Accordion section headers */
+.annotation-section-header {
+ font-size: 0.7rem;
+ font-weight: 600;
+ color: var(--text100);
+ padding: 0.3rem 0.6rem;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border-bottom: 1px solid var(--bg200);
+ user-select: none;
+ transition: background 0.1s ease;
+}
+.annotation-section-header:hover {
+ background: var(--bg100);
+}
+.annotation-section-header.current-page {
+ color: var(--text000);
+}
+.annotation-section-header.open {
+ color: var(--text000);
+}
+.annotation-section-chevron {
+ font-size: 0.55rem;
+ transition: transform 0.15s ease;
+}
+.annotation-section-body {
+ display: none;
+}
+.annotation-section-body.open {
+ display: block;
+}
+
+/* Legacy page group headers (fullscreen mode) */
+.annotation-page-group {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--text100);
+ padding: 0.5rem 0.75rem 0.25rem;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+.annotation-page-group.current-page {
+ color: var(--text000);
+}
+
+/* Annotation cards */
+.annotation-card {
+ display: flex;
+ flex-direction: column;
+ padding: 0.4rem 0.75rem;
+ cursor: pointer;
+ transition: background 0.1s ease;
+ border-left: 3px solid transparent;
+}
+.annotation-card:hover {
+ background: var(--bg100);
+}
+.annotation-card.highlighted {
+ background: var(--bg100);
+ border-left-color: var(--text100);
+}
+
+.annotation-card-row {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ min-height: 24px;
+}
+
+.annotation-card-swatch {
+ width: 10px;
+ height: 10px;
+ border-radius: 2px;
+ flex-shrink: 0;
+ border: 1px solid rgba(0, 0, 0, 0.15);
+}
+
+/* Swatch for baseline items the user cleared โ outlined with a cross
+ instead of solid fill, so the panel still shows "this existed in the
+ file and you removed it" rather than vanishing. */
+.annotation-card-swatch-cleared {
+ background: transparent;
+ border-color: #4a90d9;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.annotation-card-cleared .annotation-card-type,
+.annotation-card-cleared .annotation-card-preview {
+ opacity: 0.55;
+ text-decoration: line-through;
+}
+
+.annotation-card-type {
+ font-size: 0.7rem;
+ color: var(--text200);
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ flex-shrink: 0;
+}
+
+.annotation-card-preview {
+ flex: 1;
+ min-width: 0;
+ font-size: 0.8rem;
+ color: var(--text000);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.annotation-card-delete {
+ flex-shrink: 0;
+ margin-left: auto;
+ background: none;
+ border: none;
+ padding: 2px;
+ cursor: pointer;
+ color: var(--text200);
+ opacity: 0;
+ transition: opacity 0.1s ease, color 0.1s ease;
+ display: flex;
+ align-items: center;
+}
+.annotation-card:hover .annotation-card-delete {
+ opacity: 1;
+}
+.annotation-card-delete:hover {
+ color: #d32f2f;
+}
+
+.annotation-card-expand {
+ flex-shrink: 0;
+ font-size: 0.65rem;
+ color: var(--text200);
+ transition: transform 0.15s ease;
+}
+.annotation-card.expanded .annotation-card-expand {
+ transform: rotate(180deg);
+}
+
+.annotation-card-content {
+ display: none;
+ font-size: 0.8rem;
+ color: var(--text100);
+ padding: 0.25rem 0 0.25rem 14px;
+ white-space: pre-wrap;
+ word-break: break-word;
+ line-height: 1.4;
+}
+.annotation-card.expanded .annotation-card-content {
+ display: block;
+}
+
+/* Pulse animation for annotation elements on PDF */
+@keyframes annotation-pulse {
+ 0% { box-shadow: 0 0 0 0 rgba(255, 165, 0, 0.6); }
+ 70% { box-shadow: 0 0 0 8px rgba(255, 165, 0, 0); }
+ 100% { box-shadow: 0 0 0 0 rgba(255, 165, 0, 0); }
+}
+.annotation-pulse {
+ animation: annotation-pulse 0.6s ease-out 2;
+}
+
/* Loading Indicator (pie) */
.loading-indicator {
display: inline-flex;
@@ -468,3 +1214,36 @@ body {
.loading-indicator.error .loading-indicator-arc {
stroke: #e74c3c;
}
+
+
+/* Compact toolbar on narrow screens */
+@media (max-width: 480px) {
+ .toolbar-left {
+ display: none;
+ }
+ .toolbar {
+ justify-content: center;
+ }
+ .nav-btn,
+ .zoom-btn,
+ .search-btn,
+ .fullscreen-btn {
+ width: 28px;
+ height: 28px;
+ font-size: 0.85rem;
+ }
+ .page-input {
+ width: 40px;
+ padding: 0.2rem 0.3rem;
+ font-size: 0.8rem;
+ }
+ .total-pages,
+ .zoom-level {
+ font-size: 0.75rem;
+ min-width: 36px;
+ }
+ .toolbar-center,
+ .toolbar-right {
+ gap: 0.2rem;
+ }
+}
\ No newline at end of file
diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts
index 4278d92..45e0c74 100644
--- a/examples/pdf-server/src/mcp-app.ts
+++ b/examples/pdf-server/src/mcp-app.ts
@@ -12,36 +12,177 @@ import {
applyDocumentTheme,
applyHostStyleVariables,
} from "@modelcontextprotocol/ext-apps";
-import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
-import type { ContentBlock } from "@modelcontextprotocol/sdk/spec.types.js";
+import type {
+ CallToolResult,
+ ContentBlock,
+} from "@modelcontextprotocol/client";
+import { z } from "zod";
import * as pdfjsLib from "pdfjs-dist";
-import { TextLayer } from "pdfjs-dist";
+import { AnnotationLayer, AnnotationMode, TextLayer } from "pdfjs-dist";
+import "pdfjs-dist/web/pdf_viewer.css";
+import {
+ type PdfAnnotationDef,
+ type Rect,
+ type RectangleAnnotation,
+ type CircleAnnotation,
+ type LineAnnotation,
+ type StampAnnotation,
+ type ImageAnnotation,
+ type ImportedAnnotation,
+ type NoteAnnotation,
+ type FreetextAnnotation,
+ cssColorToRgb,
+ serializeDiff,
+ deserializeDiff,
+ mergeAnnotations,
+ computeDiff,
+ isDiffEmpty,
+ buildAnnotatedPdfBytes,
+ parseAnnotationRef,
+ importPdfjsAnnotation,
+ uint8ArrayToBase64,
+ convertFromModelCoords,
+ convertToModelCoords,
+} from "./pdf-annotations.js";
+import {
+ type TrackedAnnotation,
+ type EditEntry,
+ annotationMap,
+ formFieldValues,
+ pdfBaselineFormValues,
+ selectedAnnotationIds,
+ fieldNameToIds,
+ fieldNameToPage,
+ fieldNameToLabel,
+ fieldNameToOrder,
+ undoStack,
+ redoStack,
+ searchBarEl,
+ formLayerEl,
+} from "./viewer-state.js";
+import {
+ panelState,
+ annotationsPanelEl,
+ annotationsPanelListEl,
+ renderAnnotationPanel,
+ updateAnnotationsBadge,
+ setAnnotationPanelOpen,
+ applyFloatingPanelPosition,
+ autoDockPanel,
+ initAnnotationPanel,
+ syncSidebarSelection,
+ getFormFieldLabel,
+ getAnnotationLabel,
+ getAnnotationPreview,
+ getAnnotationColor,
+} from "./annotation-panel.js";
import "./global.css";
import "./mcp-app.css";
const MAX_MODEL_CONTEXT_LENGTH = 15000;
-const MAX_MODEL_CONTEXT_UPDATE_IMAGE_DIMENSION = 768; // Max screenshot dimension
// Configure PDF.js worker
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.mjs",
import.meta.url,
).href;
+// PDF Standard-14 fonts from CDN (requires unpkg.com in CSP connectDomains).
+// Pinned to the bundled pdfjs-dist version so font glyph indices match.
+const STANDARD_FONT_DATA_URL = `https://unpkg.com/pdfjs-dist@${pdfjsLib.version}/standard_fonts/`;
+
const log = {
info: console.log.bind(console, "[PDF-VIEWER]"),
error: console.error.bind(console, "[PDF-VIEWER]"),
};
+/**
+ * Resolve an ImageAnnotation to a src string safe for ` `.
+ * Returns the parsed-and-reserialized URL (`URL.href`) rather than the
+ * raw input so CodeQL's taint tracker recognises the sanitisation barrier
+ * (js/xss, js/client-side-unvalidated-url-redirection). Blocks
+ * `javascript:` / `vbscript:` etc. The server normally resolves
+ * imageUrl โ imageData before enqueueing; the imageUrl branch here is
+ * defense-in-depth for the server-side fetch-failure fallback.
+ */
+function safeImageSrc(def: {
+ imageData?: string;
+ mimeType?: string;
+ imageUrl?: string;
+}): string | undefined {
+ if (def.imageData) {
+ return `data:${def.mimeType || "image/png"};base64,${def.imageData}`;
+ }
+ if (!def.imageUrl) return undefined;
+ try {
+ const parsed = new URL(def.imageUrl, document.baseURI);
+ if (
+ parsed.protocol === "https:" ||
+ parsed.protocol === "http:" ||
+ parsed.protocol === "data:" ||
+ parsed.protocol === "blob:"
+ ) {
+ return parsed.href;
+ }
+ } catch {
+ // fall through
+ }
+ return undefined;
+}
+
// State
let pdfDocument: pdfjsLib.PDFDocumentProxy | null = null;
let currentPage = 1;
let totalPages = 0;
let scale = 1.0;
+const ZOOM_MIN = 0.5;
+const ZOOM_MAX = 3.0;
let pdfUrl = "";
let pdfTitle: string | undefined;
let viewUUID: string | undefined;
+let interactEnabled = false;
+/** Server-reported writability of the underlying file (fs.access W_OK). */
+let fileWritable = false;
let currentRenderTask: { cancel: () => void } | null = null;
+// Shared annotation state (annotationMap, formFieldValues, selectedAnnotationIds,
+// undoStack, redoStack, fieldNameTo*) lives in ./viewer-state.ts โ imported above.
+
+/** Cache loaded HTMLImageElement instances by annotation ID for canvas painting. */
+const imageCache = new Map();
+
+/** Annotations imported from the PDF file (baseline for diff computation). */
+let pdfBaselineAnnotations: PdfAnnotationDef[] = [];
+/** Pages whose native annotations have already been imported into the baseline. */
+const baselineScannedPages = new Set();
+/** Native-annotation ids the user deleted (from restored localStorage diff) โ
+ * the lazy per-page scan must NOT re-add these to annotationMap. */
+const restoredRemovedIds = new Set();
+
+// Dirty flag โ tracks unsaved local changes
+let isDirty = false;
+/** Whether we're currently restoring annotations (suppress dirty flag). */
+let isRestoring = false;
+/** Once the save button is shown, it stays visible (possibly disabled) until reload. */
+let saveBtnEverShown = false;
+/** True between save_pdf call and resolution; suppresses file_changed handling. */
+let saveInProgress = false;
+/** mtime returned by our most recent successful save_pdf. Compare against
+ * incoming file_changed.mtimeMs to suppress our own write's echo. */
+let lastSavedMtime: number | null = null;
+/** Incremented on every reload. Fetches/preloads from an older generation are
+ * discarded โ prevents stale rangeCache entries and stale page renders. */
+let loadGeneration = 0;
+
+let focusedFieldName: string | null = null;
+
+// Radio widget annotation ID โ its export value (buttonValue). pdf.js
+// creates without setting .value, so target.value
+// defaults to "on"; this map lets the input listener report the real value.
+const radioButtonValues = new Map();
+// Cached result of doc.getFieldObjects() โ needed for AnnotationLayer reset button support
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+let cachedFieldObjects: Record | null = null;
+
// DOM Elements
const mainEl = document.querySelector(".main") as HTMLElement;
const loadingEl = document.getElementById("loading")!;
@@ -65,7 +206,7 @@ const fullscreenBtn = document.getElementById(
) as HTMLButtonElement;
const searchBtn = document.getElementById("search-btn") as HTMLButtonElement;
searchBtn.innerHTML = ` `;
-const searchBarEl = document.getElementById("search-bar")!;
+// searchBarEl โ imported from ./viewer-state.js
const searchInputEl = document.getElementById(
"search-input",
) as HTMLInputElement;
@@ -80,6 +221,22 @@ const searchCloseBtn = document.getElementById(
"search-close-btn",
) as HTMLButtonElement;
const highlightLayerEl = document.getElementById("highlight-layer")!;
+const annotationLayerEl = document.getElementById("annotation-layer")!;
+const pageWrapperEl = document.querySelector(".page-wrapper") as HTMLElement;
+// formLayerEl โ imported from ./viewer-state.js
+const saveBtn = document.getElementById("save-btn") as HTMLButtonElement;
+const downloadBtn = document.getElementById(
+ "download-btn",
+) as HTMLButtonElement;
+const confirmDialogEl = document.getElementById(
+ "confirm-dialog",
+) as HTMLDivElement;
+const confirmTitleEl = document.getElementById("confirm-title")!;
+const confirmBodyEl = document.getElementById("confirm-body")!;
+const confirmDetailEl = document.getElementById("confirm-detail")!;
+const confirmButtonsEl = document.getElementById("confirm-buttons")!;
+
+// Annotation Panel DOM Elements & state โ ./annotation-panel.ts (imported above)
// Search state
interface SearchMatch {
@@ -109,13 +266,126 @@ const loadingIndicatorArc = loadingIndicatorEl.querySelector(
// Track current display mode
let currentDisplayMode: "inline" | "fullscreen" = "inline";
+// Whether the user has manually zoomed (disables auto fit-to-width)
+let userHasZoomed = false;
+
+/**
+ * Compute the scale that best fits the PDF page to the container.
+ * Returns null only when the container hasn't laid out yet.
+ *
+ * Inline mode: fit-to-WIDTH capped at 1.0. We shrink to fit a narrow chat
+ * column but don't blow up past natural size โ the iframe sizes itself to
+ * the page via sendSizeChanged, so growing past 1.0 would just make the
+ * iframe huge.
+ *
+ * Fullscreen mode: fit-to-PAGE capped at ZOOM_MAX. The whole page is visible
+ * without scrolling (min of width-fit and height-fit). On a wide screen this
+ * typically lands well above 1.0; on a phone in portrait, width is the
+ * tighter constraint and it degrades to fit-to-width.
+ */
+async function computeFitScale(): Promise {
+ if (!pdfDocument) return null;
+
+ try {
+ const page = await pdfDocument.getPage(currentPage);
+ const naturalViewport = page.getViewport({ scale: 1.0 });
+ const pageWidth = naturalViewport.width;
+ const pageHeight = naturalViewport.height;
+
+ const container = canvasContainerEl as HTMLElement;
+ const containerStyle = getComputedStyle(container);
+ const padX =
+ parseFloat(containerStyle.paddingLeft) +
+ parseFloat(containerStyle.paddingRight);
+ const padY =
+ parseFloat(containerStyle.paddingTop) +
+ parseFloat(containerStyle.paddingBottom);
+ const availableWidth = container.clientWidth - padX;
+ const availableHeight = container.clientHeight - padY;
+
+ if (availableWidth <= 0 || pageWidth <= 0) return null;
+
+ const widthFit = availableWidth / pageWidth;
+ if (currentDisplayMode !== "fullscreen") {
+ return Math.min(1.0, widthFit);
+ }
+ // Fullscreen: fit the WHOLE page. If height isn't measurable yet
+ // (early layout) fall back to width-fit.
+ const heightFit =
+ availableHeight > 0 && pageHeight > 0
+ ? availableHeight / pageHeight
+ : widthFit;
+ return Math.min(ZOOM_MAX, widthFit, heightFit);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Re-apply the auto-fit scale if the user hasn't taken over zoom. Runs on
+ * container resize (ResizeObserver) and display-mode transitions.
+ *
+ * The ResizeObserver path is the load-bearing one. Hosts disagree on
+ * what they send and when:
+ * - basic-host sends containerDimensions only at init, never on resize
+ * - Claude Desktop resizes the iframe and sends displayMode, but the
+ * iframe element may not have its new size yet when the message lands
+ * The observer fires after the iframe has actually laid out at the new
+ * size, so clientWidth is fresh. The hostContextChanged hooks are kept
+ * as a fast path / belt-and-suspenders.
+ */
+async function refitScale(): Promise {
+ if (!pdfDocument || userHasZoomed) return;
+ const fitScale = await computeFitScale();
+ if (fitScale !== null && Math.abs(fitScale - scale) > 0.01) {
+ scale = fitScale;
+ log.info("Refit scale:", scale);
+ renderPage();
+ }
+}
+
+// Refit when the container actually changes size. In INLINE mode this is
+// gated to width-GROWTH only โ renderPage() โ requestFitToContent() โ host
+// resizes iframe to the (smaller) page width would otherwise re-trigger the
+// observer and walk the scale down to ZOOM_MIN. In FULLSCREEN
+// requestFitToContent early-returns so there's no loop, and fit-to-page
+// needs height changes too (rotation, browser chrome on mobile).
+let lastContainerW = 0;
+let lastContainerH = 0;
+/** One-shot: refit on the next resize even if it's a shrink in inline mode.
+ * Set on fullscreenโinline so the page snaps to the new (smaller) width
+ * once the host has actually resized the iframe โ the inline `grewW` gate
+ * would otherwise swallow that shrink. */
+let forceNextResizeRefit = false;
+const containerResizeObserver = new ResizeObserver(([entry]) => {
+ const { width: w, height: h } = entry.contentRect;
+ const grewW = w > lastContainerW + 1;
+ const changed =
+ Math.abs(w - lastContainerW) > 1 || Math.abs(h - lastContainerH) > 1;
+ lastContainerW = w;
+ lastContainerH = h;
+ if (forceNextResizeRefit && changed) {
+ forceNextResizeRefit = false;
+ refitScale();
+ } else if (currentDisplayMode === "fullscreen" ? changed : grewW) {
+ refitScale();
+ }
+});
+containerResizeObserver.observe(canvasContainerEl as HTMLElement);
+
/**
* Request the host to resize the app to fit the current PDF page.
* Only applies in inline mode - fullscreen mode uses scrolling.
*/
function requestFitToContent() {
- if (currentDisplayMode === "fullscreen") {
- return; // Fullscreen uses scrolling
+ // Read the host's current state, not our cached currentDisplayMode.
+ // currentDisplayMode defaults to "inline" and handleHostContextChanged
+ // only updates it `if (ctx.displayMode)` โ if the host omits the field
+ // or the update lands one tick late, the cached value lies. We've seen
+ // this measure a near-empty pageWrapper (~85px = toolbar + padding) and
+ // shrink a fullscreen iframe to a sliver.
+ if (app.getHostContext()?.displayMode === "fullscreen") {
+ return;
}
const canvasHeight = canvasEl.height;
@@ -124,13 +394,9 @@ function requestFitToContent() {
}
// Get actual element dimensions
- const canvasContainerEl = document.querySelector(
- ".canvas-container",
- ) as HTMLElement;
- const pageWrapperEl = document.querySelector(".page-wrapper") as HTMLElement;
const toolbarEl = document.querySelector(".toolbar") as HTMLElement;
- if (!canvasContainerEl || !toolbarEl || !pageWrapperEl) {
+ if (!toolbarEl) {
return;
}
@@ -148,7 +414,20 @@ function requestFitToContent() {
const totalHeight =
toolbarHeight + paddingTop + pageWrapperHeight + paddingBottom + BUFFER;
- app.sendSizeChanged({ height: totalHeight });
+ // In inline mode (this function early-returns for fullscreen) the side panel is hidden
+ const totalWidth = pageWrapperEl.offsetWidth + BUFFER;
+
+ // pageWrapper measuring โ 0 means the canvas hasn't laid out yet (early
+ // render, hidden ancestor, etc). Sending toolbar-height-only would shrink
+ // the iframe to a sliver. The next renderPage() will measure correctly.
+ if (pageWrapperHeight < toolbarHeight) {
+ log.info(
+ `requestFitToContent: pageWrapper ${pageWrapperHeight}px < toolbar ${toolbarHeight}px โ skipping`,
+ );
+ return;
+ }
+
+ app.sendSizeChanged({ width: totalWidth, height: totalHeight });
}
// --- Search Functions ---
@@ -194,6 +473,46 @@ function performSearch(query: string) {
goToPage(match.pageNum);
}
}
+
+ // Update model context with search results
+ updatePageContext();
+}
+
+/**
+ * Silent search: populate matches and report via model context
+ * without opening the search bar or rendering highlights.
+ */
+function performSilentSearch(query: string) {
+ allMatches = [];
+ currentMatchIndex = -1;
+ searchQuery = query;
+
+ if (!query) {
+ updatePageContext();
+ return;
+ }
+
+ const lowerQuery = query.toLowerCase();
+ for (let pageNum = 1; pageNum <= totalPages; pageNum++) {
+ const pageText = pageTextCache.get(pageNum);
+ if (!pageText) continue;
+ const lowerText = pageText.toLowerCase();
+ let startIdx = 0;
+ while (true) {
+ const idx = lowerText.indexOf(lowerQuery, startIdx);
+ if (idx === -1) break;
+ allMatches.push({ pageNum, index: idx, length: query.length });
+ startIdx = idx + 1;
+ }
+ }
+
+ if (allMatches.length > 0) {
+ const idx = allMatches.findIndex((m) => m.pageNum >= currentPage);
+ currentMatchIndex = idx >= 0 ? idx : 0;
+ }
+
+ log.info(`Silent search "${query}": ${allMatches.length} matches`);
+ updatePageContext();
}
function renderHighlights() {
@@ -325,6 +644,9 @@ function openSearch() {
searchBarEl.style.display = "flex";
updateSearchUI();
searchInputEl.focus();
+ if (panelState.open && annotationsPanelEl.classList.contains("floating")) {
+ applyFloatingPanelPosition();
+ }
// Text extraction is handled by the background preloader
}
@@ -332,6 +654,9 @@ function closeSearch() {
if (!searchOpen) return;
searchOpen = false;
searchBarEl.style.display = "none";
+ if (panelState.open && annotationsPanelEl.classList.contains("floating")) {
+ applyFloatingPanelPosition();
+ }
searchQuery = "";
searchInputEl.value = "";
allMatches = [];
@@ -402,10 +727,156 @@ function showViewer() {
viewerEl.style.display = "flex";
}
+// ---------------------------------------------------------------------------
+// Confirm dialog
+// ---------------------------------------------------------------------------
+
+interface ConfirmButton {
+ label: string;
+ primary?: boolean;
+}
+
+let activeConfirmResolve: ((i: number) => void) | null = null;
+
+/**
+ * In-app confirmation overlay. Resolves to the clicked button index, the
+ * cancel index on Escape, or `-1` if pre-empted by another dialog. Callers
+ * should treat anything but the expected button index as "cancel".
+ *
+ * Button ordering follows the host's native convention: Cancel first,
+ * primary action last.
+ *
+ * @param detail Optional monospace string shown in a bordered box (e.g.
+ * a filename), matching the host's native dialog style.
+ */
+function showConfirmDialog(
+ title: string,
+ body: string,
+ buttons: ConfirmButton[],
+ detail?: string,
+): Promise {
+ // Pre-empt any open dialog: resolve it as cancelled
+ if (activeConfirmResolve) {
+ activeConfirmResolve(-1);
+ activeConfirmResolve = null;
+ }
+
+ // Escape โ first non-primary button (native Cancel-first ordering)
+ const nonPrimary = buttons.findIndex((b) => !b.primary);
+ const escIndex = nonPrimary >= 0 ? nonPrimary : buttons.length - 1;
+
+ confirmTitleEl.textContent = title;
+ confirmBodyEl.textContent = body;
+ confirmDetailEl.textContent = detail ?? "";
+ confirmButtonsEl.innerHTML = "";
+ confirmDialogEl.style.display = "flex";
+
+ return new Promise((resolve) => {
+ activeConfirmResolve = resolve;
+
+ const done = (i: number): void => {
+ if (activeConfirmResolve !== resolve) return; // already pre-empted
+ activeConfirmResolve = null;
+ confirmDialogEl.style.display = "none";
+ document.removeEventListener("keydown", onKey, true);
+ resolve(i);
+ };
+
+ const onKey = (e: KeyboardEvent): void => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ e.stopPropagation();
+ done(escIndex);
+ }
+ };
+ document.addEventListener("keydown", onKey, true);
+
+ buttons.forEach((btn, i) => {
+ const el = document.createElement("button");
+ el.textContent = btn.label;
+ el.className = btn.primary
+ ? "confirm-btn confirm-btn-primary"
+ : "confirm-btn";
+ el.addEventListener("click", () => done(i));
+ confirmButtonsEl.appendChild(el);
+ if (btn.primary) setTimeout(() => el.focus(), 0);
+ });
+ });
+}
+
+function setDirty(dirty: boolean): void {
+ if (isDirty === dirty) return;
+ isDirty = dirty;
+ updateTitleDisplay();
+ updateSaveBtn();
+}
+
+function updateSaveBtn(): void {
+ if (!fileWritable) {
+ saveBtn.style.display = "none";
+ return;
+ }
+ if (isDirty) {
+ saveBtn.style.display = "";
+ saveBtn.disabled = false;
+ saveBtnEverShown = true;
+ } else if (saveBtnEverShown) {
+ saveBtn.style.display = "";
+ saveBtn.disabled = true;
+ } else {
+ saveBtn.style.display = "none";
+ }
+}
+
+function updateTitleDisplay(): void {
+ const display = pdfTitle || pdfUrl;
+ titleEl.textContent = (isDirty ? "* " : "") + display;
+ titleEl.title = pdfUrl;
+}
+
+/**
+ * Debug overlay: fixed-position bubble, bottom-left. Pretty-printed JSON
+ * dump of whatever the server stuffed into `_meta._debug`. Tooltips inside
+ * sandboxed iframes are unreliable; this survives the cross-origin barrier
+ * and shows up in screenshots.
+ */
+function showDebugBubble(debug: unknown): void {
+ const bubble = document.createElement("div");
+ const base =
+ "position:fixed;bottom:8px;left:8px;z-index:99999;" +
+ "background:rgba(20,20,30,0.92);color:#cfe;padding:8px 12px;" +
+ "font:11px/1.4 monospace;border-radius:6px;" +
+ "box-shadow:0 2px 8px rgba(0,0,0,0.4);white-space:pre;cursor:pointer;" +
+ "transition:max-width 0.15s ease;";
+ // Collapsed: clip to 60vw. Hover: expand to fit full paths (up to ~96vw),
+ // scrollable both axes in case the JSON is tall.
+ const collapsed =
+ base +
+ "max-width:60vw;max-height:40vh;overflow:hidden;text-overflow:ellipsis;";
+ const expanded =
+ base + "max-width:calc(100vw - 32px);max-height:80vh;overflow:auto;";
+ bubble.style.cssText = collapsed;
+ // Latch expanded on click so hover-collapse doesn't fight text selection.
+ let pinned = false;
+ bubble.onmouseenter = () => {
+ bubble.style.cssText = expanded;
+ };
+ bubble.onmouseleave = () => {
+ if (!pinned) bubble.style.cssText = collapsed;
+ };
+ bubble.onclick = () => {
+ pinned = true;
+ bubble.style.cssText = expanded;
+ };
+ bubble.ondblclick = () => bubble.remove();
+ bubble.title = "Click: pin open โข Double-click: dismiss";
+ bubble.textContent = "๐ " + JSON.stringify(debug, null, 2);
+ document.body.appendChild(bubble);
+}
+
function updateControls() {
// Show URL with CSS ellipsis, full URL as tooltip, clickable to open
- titleEl.textContent = pdfUrl;
- titleEl.title = pdfUrl;
+ updateTitleDisplay();
titleEl.style.textDecoration = "underline";
titleEl.style.cursor = "pointer";
titleEl.onclick = () => app.openLink({ url: pdfUrl });
@@ -511,6 +982,50 @@ function findSelectionInText(
return undefined;
}
+/**
+ * Format search results with excerpts for model context.
+ * Limits to first 20 matches to avoid overwhelming the context.
+ */
+function formatSearchResults(): string {
+ const MAX_RESULTS = 20;
+ const EXCERPT_RADIUS = 40; // characters around the match
+
+ const lines: string[] = [];
+ const totalMatchCount = allMatches.length;
+ const currentIdx = currentMatchIndex >= 0 ? currentMatchIndex : -1;
+
+ lines.push(
+ `\nSearch: "${searchQuery}" (${totalMatchCount} match${totalMatchCount !== 1 ? "es" : ""} across ${new Set(allMatches.map((m) => m.pageNum)).size} page${new Set(allMatches.map((m) => m.pageNum)).size !== 1 ? "s" : ""})`,
+ );
+
+ const displayed = allMatches.slice(0, MAX_RESULTS);
+ for (let i = 0; i < displayed.length; i++) {
+ const match = displayed[i];
+ const pageText = pageTextCache.get(match.pageNum) || "";
+ const start = Math.max(0, match.index - EXCERPT_RADIUS);
+ const end = Math.min(
+ pageText.length,
+ match.index + match.length + EXCERPT_RADIUS,
+ );
+ const before = pageText.slice(start, match.index).replace(/\n/g, " ");
+ const matched = pageText.slice(match.index, match.index + match.length);
+ const after = pageText
+ .slice(match.index + match.length, end)
+ .replace(/\n/g, " ");
+ const prefix = start > 0 ? "..." : "";
+ const suffix = end < pageText.length ? "..." : "";
+ const current = i === currentIdx ? " (current)" : "";
+ lines.push(
+ ` [${i}] p.${match.pageNum}, offset ${match.index}${current}: ${prefix}${before}ยซ${matched}ยป${after}${suffix}`,
+ );
+ }
+ if (totalMatchCount > MAX_RESULTS) {
+ lines.push(` ... and ${totalMatchCount - MAX_RESULTS} more matches`);
+ }
+
+ return lines.join("\n");
+}
+
// Extract text from current page and update model context
async function updatePageContext() {
if (!pdfDocument) return;
@@ -547,15 +1062,79 @@ async function updatePageContext() {
selection,
);
+ // Get page dimensions in PDF points for model context
+ const viewport = page.getViewport({ scale: 1.0 });
+ const pageWidthPt = Math.round(viewport.width);
+ const pageHeightPt = Math.round(viewport.height);
+
// Build context with tool ID for multi-tool disambiguation
const toolId = app.getHostContext()?.toolInfo?.id;
const header = [
`PDF viewer${toolId ? ` (${toolId})` : ""}`,
+ viewUUID ? `viewUUID: ${viewUUID}` : null,
pdfTitle ? `"${pdfTitle}"` : pdfUrl,
`Current Page: ${currentPage}/${totalPages}`,
- ].join(" | ");
+ `Page size: ${pageWidthPt}ร${pageHeightPt}pt (coordinates: origin at top-left, Y increases downward)`,
+ ]
+ .filter(Boolean)
+ .join(" | ");
- const contextText = `${header}\n\nPage content:\n${content}`;
+ // Include search status if active
+ let searchSection = "";
+ if (searchOpen && searchQuery && allMatches.length > 0) {
+ searchSection = formatSearchResults();
+ } else if (searchOpen && searchQuery) {
+ searchSection = `\nSearch: "${searchQuery}" (no matches found)`;
+ }
+
+ // Include annotation details if any exist
+ let annotationSection = "";
+ if (annotationMap.size > 0) {
+ const onThisPage = [...annotationMap.values()].filter(
+ (t) => t.def.page === currentPage,
+ );
+ annotationSection = `\nAnnotations: ${onThisPage.length} on this page, ${annotationMap.size} total`;
+ if (formFieldValues.size > 0) {
+ annotationSection += ` | ${formFieldValues.size} form field(s) filled`;
+ }
+ // List annotations on current page with their coordinates (in model space)
+ if (onThisPage.length > 0) {
+ annotationSection +=
+ "\nAnnotations on this page (visible in screenshot):";
+ for (const t of onThisPage) {
+ const d = convertToModelCoords(t.def, pageHeightPt);
+ const selected = selectedAnnotationIds.has(d.id) ? " (SELECTED)" : "";
+ if ("rects" in d && d.rects.length > 0) {
+ const r = d.rects[0];
+ annotationSection += `\n [${d.id}] ${d.type} at (${Math.round(r.x)},${Math.round(r.y)}) ${Math.round(r.width)}x${Math.round(r.height)}${selected}`;
+ } else if ("x" in d && "y" in d) {
+ annotationSection += `\n [${d.id}] ${d.type} at (${Math.round(d.x)},${Math.round(d.y)})${selected}`;
+ }
+ }
+ }
+ }
+
+ // Include focused field or selected annotation info
+ let focusSection = "";
+ if (selectedAnnotationIds.size > 0) {
+ const ids = [...selectedAnnotationIds];
+ const descs = ids.map((selId) => {
+ const tracked = annotationMap.get(selId);
+ if (!tracked) return selId;
+ return `[${selId}] (${tracked.def.type})`;
+ });
+ focusSection = `\nSelected: ${descs.join(", ")}`;
+ }
+ if (focusedFieldName) {
+ const label = getFormFieldLabel(focusedFieldName);
+ const value = formFieldValues.get(focusedFieldName);
+ focusSection += `\nFocused field: "${label}" (name="${focusedFieldName}")`;
+ if (value !== undefined) {
+ focusSection += ` = ${JSON.stringify(value)}`;
+ }
+ }
+
+ const contextText = `${header}${searchSection}${annotationSection}${focusSection}\n\nPage content:\n${content}`;
// Build content array with text and optional screenshot
const contentBlocks: ContentBlock[] = [{ type: "text", text: contextText }];
@@ -563,34 +1142,15 @@ async function updatePageContext() {
// Add screenshot if host supports image content
if (app.getHostCapabilities()?.updateModelContext?.image) {
try {
- // Scale down to reduce token usage (tokens depend on dimensions)
- const sourceCanvas = canvasEl;
- const scale = Math.min(
- 1,
- MAX_MODEL_CONTEXT_UPDATE_IMAGE_DIMENSION /
- Math.max(sourceCanvas.width, sourceCanvas.height),
- );
- const targetWidth = Math.round(sourceCanvas.width * scale);
- const targetHeight = Math.round(sourceCanvas.height * scale);
-
- const tempCanvas = document.createElement("canvas");
- tempCanvas.width = targetWidth;
- tempCanvas.height = targetHeight;
- const ctx = tempCanvas.getContext("2d");
- if (ctx) {
- ctx.drawImage(sourceCanvas, 0, 0, targetWidth, targetHeight);
- const dataUrl = tempCanvas.toDataURL("image/png");
- const base64Data = dataUrl.split(",")[1];
- if (base64Data) {
- contentBlocks.push({
- type: "image",
- data: base64Data,
- mimeType: "image/png",
- });
- log.info(
- `Added screenshot to model context (${targetWidth}x${targetHeight})`,
- );
- }
+ // Render offscreen with ENABLE_STORAGE so filled form fields are visible
+ const base64Data = await renderPageOffscreen(currentPage);
+ if (base64Data) {
+ contentBlocks.push({
+ type: "image",
+ data: base64Data,
+ mimeType: "image/jpeg",
+ });
+ log.info("Added screenshot to model context");
}
} catch (err) {
log.info("Failed to capture screenshot:", err);
@@ -603,6 +1163,2115 @@ async function updatePageContext() {
}
}
+// =============================================================================
+// Annotation Rendering
+// =============================================================================
+
+/**
+ * Convert PDF coordinates (bottom-left origin) to screen coordinates
+ * relative to the page wrapper. PDF.js viewport handles rotation and scale.
+ */
+function pdfRectToScreen(
+ rect: Rect,
+ viewport: { width: number; height: number; scale: number },
+): { left: number; top: number; width: number; height: number } {
+ const s = viewport.scale;
+ // PDF origin is bottom-left, screen origin is top-left
+ const left = rect.x * s;
+ const top = viewport.height - (rect.y + rect.height) * s;
+ const width = rect.width * s;
+ const height = rect.height * s;
+ return { left, top, width, height };
+}
+
+function pdfPointToScreen(
+ x: number,
+ y: number,
+ viewport: { width: number; height: number; scale: number },
+): { left: number; top: number } {
+ const s = viewport.scale;
+ return { left: x * s, top: viewport.height - y * s };
+}
+
+/** Convert a screen-space delta (pixels) to a PDF-space delta. */
+function screenToPdfDelta(dx: number, dy: number): { dx: number; dy: number } {
+ return { dx: dx / scale, dy: -dy / scale };
+}
+
+// =============================================================================
+// Undo / Redo
+// =============================================================================
+
+function pushEdit(entry: EditEntry): void {
+ undoStack.push(entry);
+ redoStack.length = 0;
+}
+
+function undo(): void {
+ const entry = undoStack.pop();
+ if (!entry) return;
+ redoStack.push(entry);
+ applyEdit(entry, true);
+}
+
+function redo(): void {
+ const entry = redoStack.pop();
+ if (!entry) return;
+ undoStack.push(entry);
+ applyEdit(entry, false);
+}
+
+function applyEdit(entry: EditEntry, reverse: boolean): void {
+ const state = reverse ? entry.before : entry.after;
+ if (entry.type === "add") {
+ if (reverse) {
+ removeAnnotation(entry.id, true);
+ } else {
+ addAnnotation(state!, true);
+ }
+ } else if (entry.type === "remove") {
+ if (reverse) {
+ addAnnotation(state!, true);
+ } else {
+ removeAnnotation(entry.id, true);
+ }
+ } else {
+ if (state) {
+ const tracked = annotationMap.get(entry.id);
+ if (tracked) {
+ tracked.def = { ...state };
+ } else {
+ annotationMap.set(entry.id, { def: { ...state }, elements: [] });
+ }
+ }
+ renderAnnotationsForPage(currentPage);
+ renderAnnotationPanel();
+ }
+ persistAnnotations();
+}
+
+// =============================================================================
+// Selection
+// =============================================================================
+
+/**
+ * Select annotation(s). Pass null to deselect all.
+ * If additive is true, toggle the given id without clearing existing selection.
+ */
+function selectAnnotation(id: string | null, additive = false): void {
+ if (!additive) {
+ // Clear all existing selection visuals
+ for (const prevId of selectedAnnotationIds) {
+ const tracked = annotationMap.get(prevId);
+ if (tracked) {
+ for (const el of tracked.elements) {
+ el.classList.remove("annotation-selected");
+ }
+ }
+ }
+ // Remove handles
+ for (const h of annotationLayerEl.querySelectorAll(
+ ".annotation-handle, .annotation-handle-rotate",
+ )) {
+ h.remove();
+ }
+ selectedAnnotationIds.clear();
+ }
+
+ if (id) {
+ if (additive && selectedAnnotationIds.has(id)) {
+ // Toggle off
+ selectedAnnotationIds.delete(id);
+ const tracked = annotationMap.get(id);
+ if (tracked) {
+ for (const el of tracked.elements) {
+ el.classList.remove("annotation-selected");
+ }
+ }
+ } else {
+ selectedAnnotationIds.add(id);
+ }
+ }
+
+ // Apply selection visuals + handles on all selected
+ // Only show handles when exactly one annotation is selected
+ for (const selId of selectedAnnotationIds) {
+ const tracked = annotationMap.get(selId);
+ if (tracked) {
+ for (const el of tracked.elements) {
+ el.classList.add("annotation-selected");
+ }
+ if (selectedAnnotationIds.size === 1) {
+ showHandles(tracked);
+ }
+ }
+ }
+
+ // Auto-expand the accordion section for the selected annotation's page
+ if (id) {
+ const tracked = annotationMap.get(id);
+ if (tracked) {
+ panelState.openAccordionSection = `page-${tracked.def.page}`;
+ }
+ }
+
+ // Re-render the panel so accordion sections open/close to match selection
+ renderAnnotationPanel();
+
+ // Scroll the selected card into view in the sidebar
+ if (id) {
+ const card = annotationsPanelListEl.querySelector(
+ `.annotation-card[data-annotation-id="${id}"]`,
+ );
+ if (card) {
+ card.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ }
+ }
+
+ // Sync sidebar
+ syncSidebarSelection();
+ // Auto-dock floating panel away from selected annotation
+ if (
+ selectedAnnotationIds.size > 0 &&
+ annotationsPanelEl.classList.contains("floating") &&
+ panelState.open
+ ) {
+ autoDockPanel();
+ }
+ // Update model context with selection info
+ updatePageContext();
+}
+
+/** Types that support resize handles (need width/height). */
+const RESIZABLE_TYPES = new Set(["rectangle", "circle", "image"]);
+/** Types that support rotation. */
+const ROTATABLE_TYPES = new Set(["rectangle", "stamp", "image"]);
+
+function showHandles(tracked: TrackedAnnotation): void {
+ const def = tracked.def;
+ if (tracked.elements.length === 0) return;
+ if (!RESIZABLE_TYPES.has(def.type) && !ROTATABLE_TYPES.has(def.type)) return;
+
+ const el = tracked.elements[0];
+
+ // Resize handles (corners) for types with width/height
+ if (RESIZABLE_TYPES.has(def.type) && "width" in def && "height" in def) {
+ for (const corner of ["nw", "ne", "sw", "se"] as const) {
+ const handle = document.createElement("div");
+ handle.className = `annotation-handle ${corner}`;
+ handle.dataset.corner = corner;
+ const isImagePreserve =
+ def.type === "image" &&
+ ((def as ImageAnnotation).aspect ?? "preserve") === "preserve";
+ handle.title = isImagePreserve
+ ? "Drag to resize (Shift for free resize)"
+ : "Drag to resize (Shift to keep proportions)";
+ setupResizeHandle(handle, tracked, corner);
+ el.appendChild(handle);
+ }
+ }
+
+ // Rotate handle for rotatable types
+ if (ROTATABLE_TYPES.has(def.type)) {
+ const handle = document.createElement("div");
+ handle.className = "annotation-handle-rotate";
+ handle.title = "Drag to rotate";
+ setupRotateHandle(handle, tracked);
+ el.appendChild(handle);
+ }
+}
+
+// =============================================================================
+// Drag (move)
+// =============================================================================
+
+const DRAGGABLE_TYPES = new Set([
+ "rectangle",
+ "circle",
+ "line",
+ "freetext",
+ "stamp",
+ "note",
+ "image",
+ // "imported" is draggable in the UI but the move does NOT persist to the
+ // PDF on save (addAnnotationDicts skips it). Resize/rotate stay disabled
+ // โ the appearance bitmap would just stretch.
+ "imported",
+]);
+
+function setupAnnotationInteraction(
+ el: HTMLElement,
+ tracked: TrackedAnnotation,
+): void {
+ // Click to select (Shift+click for additive multi-select)
+ el.addEventListener("mousedown", (e) => {
+ // Ignore if clicking on a handle
+ if (
+ (e.target as HTMLElement).classList.contains("annotation-handle") ||
+ (e.target as HTMLElement).classList.contains("annotation-handle-rotate")
+ ) {
+ return;
+ }
+ e.stopPropagation();
+ selectAnnotation(tracked.def.id, e.shiftKey);
+
+ // Start drag for draggable types (only single-select)
+ if (DRAGGABLE_TYPES.has(tracked.def.type) && !e.shiftKey) {
+ startDrag(e, tracked);
+ }
+ });
+
+ // Double-click to send message to modify annotation (same as sidebar card)
+ el.addEventListener("dblclick", (e) => {
+ e.stopPropagation();
+ selectAnnotation(tracked.def.id);
+ const label = getAnnotationLabel(tracked.def);
+ const previewText = getAnnotationPreview(tracked.def);
+ const desc = previewText ? `${label}: ${previewText}` : label;
+ void app
+ .sendMessage({
+ role: "user",
+ content: [{ type: "text", text: `update ${desc}: ` }],
+ })
+ .catch(log.error);
+ });
+}
+
+function startDrag(e: MouseEvent, tracked: TrackedAnnotation): void {
+ const def = tracked.def;
+ const startX = e.clientX;
+ const startY = e.clientY;
+ const beforeDef = { ...def } as PdfAnnotationDef;
+ let moved = false;
+
+ // Store original element positions
+ const originalPositions = tracked.elements.map((el) => ({
+ left: parseFloat(el.style.left),
+ top: parseFloat(el.style.top),
+ }));
+
+ document.body.style.cursor = "grabbing";
+ for (const el of tracked.elements) {
+ el.classList.add("annotation-dragging");
+ }
+
+ const onMouseMove = (ev: MouseEvent) => {
+ const dx = ev.clientX - startX;
+ const dy = ev.clientY - startY;
+ if (Math.abs(dx) > 2 || Math.abs(dy) > 2) moved = true;
+ // Move elements directly for smooth feedback
+ for (let i = 0; i < tracked.elements.length; i++) {
+ tracked.elements[i].style.left = `${originalPositions[i].left + dx}px`;
+ tracked.elements[i].style.top = `${originalPositions[i].top + dy}px`;
+ }
+ };
+
+ const onMouseUp = (ev: MouseEvent) => {
+ document.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("mouseup", onMouseUp);
+ document.body.style.cursor = "";
+ for (const el of tracked.elements) {
+ el.classList.remove("annotation-dragging");
+ }
+
+ if (!moved) return;
+
+ const dx = ev.clientX - startX;
+ const dy = ev.clientY - startY;
+ const pdfDelta = screenToPdfDelta(dx, dy);
+
+ // Apply move to def
+ applyMoveToDef(
+ tracked.def as PdfAnnotationDef & { x: number; y: number },
+ pdfDelta.dx,
+ pdfDelta.dy,
+ );
+
+ const afterDef = { ...tracked.def } as PdfAnnotationDef;
+ pushEdit({
+ type: "update",
+ id: def.id,
+ before: beforeDef,
+ after: afterDef,
+ });
+ persistAnnotations();
+ // Re-render to get correct positions
+ renderAnnotationsForPage(currentPage);
+ // Re-select to show handles
+ selectAnnotation(def.id);
+ };
+
+ document.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("mouseup", onMouseUp);
+}
+
+function applyMoveToDef(
+ def: PdfAnnotationDef & { x?: number; y?: number },
+ dx: number,
+ dy: number,
+): void {
+ if (def.type === "line") {
+ def.x1 += dx;
+ def.y1 += dy;
+ def.x2 += dx;
+ def.y2 += dy;
+ } else if ("x" in def && "y" in def) {
+ def.x! += dx;
+ def.y! += dy;
+ }
+}
+
+// =============================================================================
+// Resize (rectangle, circle, image)
+// =============================================================================
+
+function setupResizeHandle(
+ handle: HTMLElement,
+ tracked: TrackedAnnotation,
+ corner: "nw" | "ne" | "sw" | "se",
+): void {
+ handle.addEventListener("mousedown", (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ const def = tracked.def as
+ | RectangleAnnotation
+ | CircleAnnotation
+ | ImageAnnotation;
+ const beforeDef = { ...def };
+ const startX = e.clientX;
+ const startY = e.clientY;
+ const aspectRatio = beforeDef.height / beforeDef.width;
+
+ const onMouseMove = (ev: MouseEvent) => {
+ const dxScreen = ev.clientX - startX;
+ const dyScreen = ev.clientY - startY;
+ const pdfD = screenToPdfDelta(dxScreen, dyScreen);
+
+ // Reset to before state then apply delta
+ let newX = beforeDef.x;
+ let newY = beforeDef.y;
+ let newW = beforeDef.width;
+ let newH = beforeDef.height;
+
+ // In PDF coords: x goes right, y goes up
+ if (corner.includes("w")) {
+ newX += pdfD.dx;
+ newW -= pdfD.dx;
+ } else {
+ newW += pdfD.dx;
+ }
+ if (corner.includes("s")) {
+ newY += pdfD.dy;
+ newH -= pdfD.dy;
+ } else {
+ newH += pdfD.dy;
+ }
+
+ // Constrain aspect ratio:
+ // - For images: preserve by default (Shift to ignore), unless aspect="ignore"
+ // - For other shapes: Shift to preserve
+ const isImage = def.type === "image";
+ const imageAspect = isImage
+ ? ((def as ImageAnnotation).aspect ?? "preserve")
+ : undefined;
+ const constrainAspect = isImage
+ ? imageAspect === "preserve"
+ ? !ev.shiftKey // preserve by default, Shift to free-resize
+ : ev.shiftKey // ignore by default, Shift to constrain
+ : ev.shiftKey; // non-image: Shift to constrain
+
+ if (constrainAspect) {
+ // Use the wider dimension to drive the other
+ const candidateH = newW * aspectRatio;
+ newH = candidateH;
+ // Adjust origin for corners that anchor at bottom/left
+ if (corner.includes("s")) {
+ newY = beforeDef.y + beforeDef.height - newH;
+ }
+ if (corner.includes("w")) {
+ // width changed by resize, x was already adjusted above
+ }
+ }
+
+ // Enforce minimum size
+ if (newW < 5) {
+ newW = 5;
+ }
+ if (newH < 5) {
+ newH = 5;
+ }
+
+ def.x = newX;
+ def.y = newY;
+ def.width = newW;
+ def.height = newH;
+
+ // Re-render for live feedback
+ renderAnnotationsForPage(currentPage);
+ selectAnnotation(def.id);
+ };
+
+ const onMouseUp = () => {
+ document.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("mouseup", onMouseUp);
+
+ const afterDef = { ...def };
+ pushEdit({
+ type: "update",
+ id: def.id,
+ before: beforeDef,
+ after: afterDef,
+ });
+ persistAnnotations();
+ };
+
+ document.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("mouseup", onMouseUp);
+ });
+}
+
+// =============================================================================
+// Rotate (stamp, rectangle)
+// =============================================================================
+
+function setupRotateHandle(
+ handle: HTMLElement,
+ tracked: TrackedAnnotation,
+): void {
+ handle.addEventListener("mousedown", (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ const def = tracked.def as
+ | StampAnnotation
+ | RectangleAnnotation
+ | ImageAnnotation;
+ const beforeDef = { ...def };
+ const el = tracked.elements[0];
+ const rect = el.getBoundingClientRect();
+ const centerX = rect.left + rect.width / 2;
+ const centerY = rect.top + rect.height / 2;
+
+ const onMouseMove = (ev: MouseEvent) => {
+ const angle = Math.atan2(ev.clientY - centerY, ev.clientX - centerX);
+ // Convert to degrees, offset so 0 = pointing up
+ let degrees = (angle * 180) / Math.PI + 90;
+ // Normalize
+ if (degrees < 0) degrees += 360;
+ if (degrees > 360) degrees -= 360;
+ // Snap to 15-degree increments when close
+ const snapped = Math.round(degrees / 15) * 15;
+ if (Math.abs(degrees - snapped) < 3) degrees = snapped;
+
+ def.rotation = Math.round(degrees);
+ renderAnnotationsForPage(currentPage);
+ selectAnnotation(def.id);
+ };
+
+ const onMouseUp = () => {
+ document.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("mouseup", onMouseUp);
+
+ const afterDef = { ...def };
+ pushEdit({
+ type: "update",
+ id: def.id,
+ before: beforeDef,
+ after: afterDef,
+ });
+ persistAnnotations();
+ };
+
+ document.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("mouseup", onMouseUp);
+ });
+}
+
+/**
+ * Paint annotations for a page onto a 2D canvas context.
+ * Used to include annotations in screenshots sent to the model.
+ */
+function paintAnnotationsOnCanvas(
+ ctx: CanvasRenderingContext2D,
+ pageNum: number,
+ viewport: { width: number; height: number; scale: number },
+): void {
+ for (const tracked of annotationMap.values()) {
+ const def = tracked.def;
+ if (def.page !== pageNum) continue;
+
+ const color = getAnnotationColor(def);
+
+ switch (def.type) {
+ case "highlight":
+ ctx.save();
+ ctx.globalAlpha = 0.35;
+ ctx.fillStyle = def.color || "rgba(255, 255, 0, 1)";
+ for (const rect of def.rects) {
+ const s = pdfRectToScreen(rect, viewport);
+ ctx.fillRect(s.left, s.top, s.width, s.height);
+ }
+ ctx.restore();
+ break;
+
+ case "underline":
+ ctx.save();
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 2;
+ for (const rect of def.rects) {
+ const s = pdfRectToScreen(rect, viewport);
+ ctx.beginPath();
+ ctx.moveTo(s.left, s.top + s.height);
+ ctx.lineTo(s.left + s.width, s.top + s.height);
+ ctx.stroke();
+ }
+ ctx.restore();
+ break;
+
+ case "strikethrough":
+ ctx.save();
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 2;
+ for (const rect of def.rects) {
+ const s = pdfRectToScreen(rect, viewport);
+ const midY = s.top + s.height / 2;
+ ctx.beginPath();
+ ctx.moveTo(s.left, midY);
+ ctx.lineTo(s.left + s.width, midY);
+ ctx.stroke();
+ }
+ ctx.restore();
+ break;
+
+ case "note": {
+ const pos = pdfPointToScreen(def.x, def.y, viewport);
+ ctx.save();
+ ctx.fillStyle = color;
+ ctx.globalAlpha = 0.8;
+ ctx.fillRect(pos.left, pos.top - 16, 16, 16);
+ ctx.restore();
+ break;
+ }
+
+ case "rectangle": {
+ const s = pdfRectToScreen(
+ { x: def.x, y: def.y, width: def.width, height: def.height },
+ viewport,
+ );
+ ctx.save();
+ if (def.rotation) {
+ const cx = s.left + s.width / 2;
+ const cy = s.top + s.height / 2;
+ ctx.translate(cx, cy);
+ ctx.rotate((def.rotation * Math.PI) / 180);
+ ctx.translate(-cx, -cy);
+ }
+ if (def.fillColor) {
+ ctx.globalAlpha = 0.3;
+ ctx.fillStyle = def.fillColor;
+ ctx.fillRect(s.left, s.top, s.width, s.height);
+ }
+ ctx.globalAlpha = 1;
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 2;
+ ctx.strokeRect(s.left, s.top, s.width, s.height);
+ ctx.restore();
+ break;
+ }
+
+ case "freetext": {
+ const pos = pdfPointToScreen(def.x, def.y, viewport);
+ ctx.save();
+ ctx.fillStyle = color;
+ ctx.font = `${(def.fontSize || 12) * viewport.scale}px Helvetica, Arial, sans-serif`;
+ ctx.fillText(def.content, pos.left, pos.top);
+ ctx.restore();
+ break;
+ }
+
+ case "stamp": {
+ const pos = pdfPointToScreen(def.x, def.y, viewport);
+ ctx.save();
+ ctx.translate(pos.left, pos.top);
+ if (def.rotation) ctx.rotate((def.rotation * Math.PI) / 180);
+ ctx.strokeStyle = color;
+ ctx.fillStyle = color;
+ ctx.lineWidth = 3;
+ ctx.globalAlpha = 0.6;
+ ctx.font = `bold ${24 * viewport.scale}px Helvetica, Arial, sans-serif`;
+ const metrics = ctx.measureText(def.label);
+ const pad = 8 * viewport.scale;
+ ctx.strokeRect(
+ -pad,
+ -24 * viewport.scale - pad,
+ metrics.width + pad * 2,
+ 24 * viewport.scale + pad * 2,
+ );
+ ctx.fillText(def.label, 0, 0);
+ ctx.restore();
+ break;
+ }
+
+ case "circle": {
+ const s = pdfRectToScreen(
+ { x: def.x, y: def.y, width: def.width, height: def.height },
+ viewport,
+ );
+ ctx.save();
+ if (def.fillColor) {
+ ctx.globalAlpha = 0.3;
+ ctx.fillStyle = def.fillColor;
+ ctx.beginPath();
+ ctx.ellipse(
+ s.left + s.width / 2,
+ s.top + s.height / 2,
+ s.width / 2,
+ s.height / 2,
+ 0,
+ 0,
+ Math.PI * 2,
+ );
+ ctx.fill();
+ }
+ ctx.globalAlpha = 1;
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.ellipse(
+ s.left + s.width / 2,
+ s.top + s.height / 2,
+ s.width / 2,
+ s.height / 2,
+ 0,
+ 0,
+ Math.PI * 2,
+ );
+ ctx.stroke();
+ ctx.restore();
+ break;
+ }
+
+ case "line": {
+ const p1 = pdfPointToScreen(def.x1, def.y1, viewport);
+ const p2 = pdfPointToScreen(def.x2, def.y2, viewport);
+ ctx.save();
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(p1.left, p1.top);
+ ctx.lineTo(p2.left, p2.top);
+ ctx.stroke();
+ ctx.restore();
+ break;
+ }
+
+ case "image": {
+ const s = pdfRectToScreen(
+ { x: def.x, y: def.y, width: def.width, height: def.height },
+ viewport,
+ );
+ // Try to draw from cache
+ const cachedImg = imageCache.get(def.id);
+ if (cachedImg) {
+ ctx.save();
+ if (def.rotation) {
+ const cx = s.left + s.width / 2;
+ const cy = s.top + s.height / 2;
+ ctx.translate(cx, cy);
+ ctx.rotate((def.rotation * Math.PI) / 180);
+ ctx.translate(-cx, -cy);
+ }
+ ctx.drawImage(cachedImg, s.left, s.top, s.width, s.height);
+ ctx.restore();
+ } else {
+ // Load image asynchronously into cache for next paint
+ const src = safeImageSrc(def);
+ if (src) {
+ const img = new Image();
+ img.onload = () => {
+ imageCache.set(def.id, img);
+ };
+ img.src = src;
+ }
+ // Draw placeholder border
+ ctx.save();
+ ctx.strokeStyle = "#999";
+ ctx.lineWidth = 1;
+ ctx.setLineDash([4, 4]);
+ ctx.strokeRect(s.left, s.top, s.width, s.height);
+ ctx.restore();
+ }
+ break;
+ }
+
+ case "imported": {
+ const s = pdfRectToScreen(
+ { x: def.x, y: def.y, width: def.width, height: def.height },
+ viewport,
+ );
+ const bmp = annotationCanvasMap.get(def.pdfjsId);
+ ctx.save();
+ if (bmp) {
+ ctx.drawImage(bmp, s.left, s.top, s.width, s.height);
+ } else {
+ ctx.strokeStyle = "#666";
+ ctx.lineWidth = 1;
+ ctx.setLineDash([3, 3]);
+ ctx.strokeRect(s.left, s.top, s.width, s.height);
+ }
+ ctx.restore();
+ break;
+ }
+ }
+ }
+}
+
+function renderAnnotationsForPage(pageNum: number): void {
+ // Clear existing annotation elements
+ annotationLayerEl.innerHTML = "";
+
+ // Remove tracked element refs for all annotations
+ for (const tracked of annotationMap.values()) {
+ tracked.elements = [];
+ }
+
+ if (!pdfDocument) return;
+
+ // Get viewport for coordinate conversion
+ const vp = {
+ width: parseFloat(annotationLayerEl.style.width) || 0,
+ height: parseFloat(annotationLayerEl.style.height) || 0,
+ scale,
+ };
+ if (vp.width === 0 || vp.height === 0) return;
+
+ for (const tracked of annotationMap.values()) {
+ const def = tracked.def;
+ if (def.page !== pageNum) continue;
+
+ const elements = renderAnnotation(def, vp);
+ tracked.elements = elements;
+ for (const el of elements) {
+ // Set up selection + drag/resize/rotate interactions
+ setupAnnotationInteraction(el, tracked);
+ annotationLayerEl.appendChild(el);
+ }
+ // Restore selection state after re-render
+ if (selectedAnnotationIds.has(def.id)) {
+ for (const el of elements) {
+ el.classList.add("annotation-selected");
+ }
+ if (selectedAnnotationIds.size === 1) {
+ showHandles(tracked);
+ }
+ }
+ }
+
+ // Refresh panel to update current-page highlighting
+ renderAnnotationPanel();
+}
+
+function renderAnnotation(
+ def: PdfAnnotationDef,
+ viewport: { width: number; height: number; scale: number },
+): HTMLElement[] {
+ switch (def.type) {
+ case "highlight": {
+ // Force translucency: def.color is an opaque hex (e.g. "#ffff00"), which
+ // would override the rgba()/mix-blend-mode in CSS and hide the text.
+ const rgb = def.color ? cssColorToRgb(def.color) : null;
+ const bg = rgb
+ ? `rgba(${Math.round(rgb.r * 255)}, ${Math.round(rgb.g * 255)}, ${Math.round(rgb.b * 255)}, 0.35)`
+ : undefined;
+ return renderRectsAnnotation(
+ def.rects,
+ "annotation-highlight",
+ viewport,
+ bg ? { background: bg } : {},
+ );
+ }
+ case "underline":
+ return renderRectsAnnotation(
+ def.rects,
+ "annotation-underline",
+ viewport,
+ def.color ? { borderBottomColor: def.color } : {},
+ );
+ case "strikethrough":
+ return renderRectsAnnotation(
+ def.rects,
+ "annotation-strikethrough",
+ viewport,
+ {},
+ def.color,
+ );
+ case "note":
+ return [renderNoteAnnotation(def, viewport)];
+ case "rectangle":
+ return [renderRectangleAnnotation(def, viewport)];
+ case "freetext":
+ return [renderFreetextAnnotation(def, viewport)];
+ case "stamp":
+ return [renderStampAnnotation(def, viewport)];
+ case "circle":
+ return [renderCircleAnnotation(def, viewport)];
+ case "line":
+ return [renderLineAnnotation(def, viewport)];
+ case "image":
+ return [renderImageAnnotation(def, viewport)];
+ case "imported":
+ return [renderImportedAnnotation(def, viewport)];
+ }
+}
+
+function renderRectsAnnotation(
+ rects: Rect[],
+ className: string,
+ viewport: { width: number; height: number; scale: number },
+ extraStyles: Record,
+ strikeColor?: string,
+): HTMLElement[] {
+ return rects.map((rect) => {
+ const screen = pdfRectToScreen(rect, viewport);
+ const el = document.createElement("div");
+ el.className = className;
+ el.style.left = `${screen.left}px`;
+ el.style.top = `${screen.top}px`;
+ el.style.width = `${screen.width}px`;
+ el.style.height = `${screen.height}px`;
+ for (const [k, v] of Object.entries(extraStyles)) {
+ (el.style as unknown as Record)[k] = v;
+ }
+ if (strikeColor) {
+ // Set color for the ::after pseudo-element via CSS custom property
+ el.style.setProperty("--strike-color", strikeColor);
+ el.querySelector("::after"); // no-op, style via CSS instead
+ // Actually use inline style on a child element for the line
+ const line = document.createElement("div");
+ line.style.position = "absolute";
+ line.style.left = "0";
+ line.style.right = "0";
+ line.style.top = "50%";
+ line.style.borderTop = `2px solid ${strikeColor}`;
+ el.appendChild(line);
+ }
+ return el;
+ });
+}
+
+function renderNoteAnnotation(
+ def: NoteAnnotation,
+ viewport: { width: number; height: number; scale: number },
+): HTMLElement {
+ const pos = pdfPointToScreen(def.x, def.y, viewport);
+ const el = document.createElement("div");
+ el.className = "annotation-note";
+ el.style.left = `${pos.left}px`;
+ el.style.top = `${pos.top - 20}px`; // offset up so note icon is at the point
+ if (def.color) el.style.color = def.color;
+
+ const tooltip = document.createElement("div");
+ tooltip.className = "annotation-tooltip";
+ tooltip.textContent = def.content;
+ el.appendChild(tooltip);
+
+ return el;
+}
+
+function renderRectangleAnnotation(
+ def: RectangleAnnotation,
+ viewport: { width: number; height: number; scale: number },
+): HTMLElement {
+ const screen = pdfRectToScreen(
+ { x: def.x, y: def.y, width: def.width, height: def.height },
+ viewport,
+ );
+ const el = document.createElement("div");
+ el.className = "annotation-rectangle";
+ el.style.left = `${screen.left}px`;
+ el.style.top = `${screen.top}px`;
+ el.style.width = `${screen.width}px`;
+ el.style.height = `${screen.height}px`;
+ if (def.color) el.style.borderColor = def.color;
+ if (def.fillColor) el.style.backgroundColor = def.fillColor;
+ if (def.rotation) {
+ el.style.transform = `rotate(${def.rotation}deg)`;
+ el.style.transformOrigin = "center center";
+ }
+ return el;
+}
+
+function renderFreetextAnnotation(
+ def: FreetextAnnotation,
+ viewport: { width: number; height: number; scale: number },
+): HTMLElement {
+ const pos = pdfPointToScreen(def.x, def.y, viewport);
+ const el = document.createElement("div");
+ el.className = "annotation-freetext";
+ el.style.left = `${pos.left}px`;
+ el.style.top = `${pos.top}px`;
+ el.style.fontSize = `${(def.fontSize || 12) * viewport.scale}px`;
+ if (def.color) el.style.color = def.color;
+ el.textContent = def.content;
+ return el;
+}
+
+function renderStampAnnotation(
+ def: StampAnnotation,
+ viewport: { width: number; height: number; scale: number },
+): HTMLElement {
+ const pos = pdfPointToScreen(def.x, def.y, viewport);
+ const el = document.createElement("div");
+ el.className = "annotation-stamp";
+ el.style.left = `${pos.left}px`;
+ el.style.top = `${pos.top}px`;
+ el.style.fontSize = `${24 * viewport.scale}px`;
+ if (def.color) el.style.color = def.color;
+ if (def.rotation) {
+ el.style.transform = `rotate(${def.rotation}deg)`;
+ el.style.transformOrigin = "center center";
+ }
+ el.textContent = def.label;
+ return el;
+}
+
+function renderCircleAnnotation(
+ def: CircleAnnotation,
+ viewport: { width: number; height: number; scale: number },
+): HTMLElement {
+ const screen = pdfRectToScreen(
+ { x: def.x, y: def.y, width: def.width, height: def.height },
+ viewport,
+ );
+ const el = document.createElement("div");
+ el.className = "annotation-circle";
+ el.style.left = `${screen.left}px`;
+ el.style.top = `${screen.top}px`;
+ el.style.width = `${screen.width}px`;
+ el.style.height = `${screen.height}px`;
+ if (def.color) el.style.borderColor = def.color;
+ if (def.fillColor) el.style.backgroundColor = def.fillColor;
+ return el;
+}
+
+function renderLineAnnotation(
+ def: LineAnnotation,
+ viewport: { width: number; height: number; scale: number },
+): HTMLElement {
+ const p1 = pdfPointToScreen(def.x1, def.y1, viewport);
+ const p2 = pdfPointToScreen(def.x2, def.y2, viewport);
+ const dx = p2.left - p1.left;
+ const dy = p2.top - p1.top;
+ const length = Math.sqrt(dx * dx + dy * dy);
+ const angle = Math.atan2(dy, dx);
+
+ const el = document.createElement("div");
+ el.className = "annotation-line";
+ el.style.left = `${p1.left}px`;
+ el.style.top = `${p1.top}px`;
+ el.style.width = `${length}px`;
+ el.style.transform = `rotate(${angle}rad)`;
+ el.style.transformOrigin = "0 0";
+ if (def.color) el.style.borderColor = def.color;
+ return el;
+}
+
+function renderImageAnnotation(
+ def: ImageAnnotation,
+ viewport: { width: number; height: number; scale: number },
+): HTMLElement {
+ const screen = pdfRectToScreen(
+ { x: def.x, y: def.y, width: def.width, height: def.height },
+ viewport,
+ );
+ const el = document.createElement("div");
+ el.className = "annotation-image";
+ el.style.left = `${screen.left}px`;
+ el.style.top = `${screen.top}px`;
+ el.style.width = `${screen.width}px`;
+ el.style.height = `${screen.height}px`;
+ if (def.rotation) {
+ el.style.transform = `rotate(${def.rotation}deg)`;
+ el.style.transformOrigin = "center center";
+ }
+
+ const imgSrc = safeImageSrc(def);
+ if (imgSrc) {
+ const img = document.createElement("img");
+ img.src = imgSrc;
+ img.style.width = "100%";
+ img.style.height = "100%";
+ img.style.display = "block";
+ img.style.pointerEvents = "none";
+ img.draggable = false;
+ el.appendChild(img);
+ }
+ return el;
+}
+
+/**
+ * Per-annotation appearance bitmaps from page.render(). Keyed by pdf.js
+ * annotation id (e.g. "118R"). Populated for the current page only โ
+ * cleared at the start of each renderPage().
+ */
+const annotationCanvasMap = new Map();
+
+function renderImportedAnnotation(
+ def: ImportedAnnotation,
+ viewport: { width: number; height: number; scale: number },
+): HTMLElement {
+ const screen = pdfRectToScreen(
+ { x: def.x, y: def.y, width: def.width, height: def.height },
+ viewport,
+ );
+ const el = document.createElement("div");
+ el.className = "annotation-imported";
+ el.style.left = `${screen.left}px`;
+ el.style.top = `${screen.top}px`;
+ el.style.width = `${screen.width}px`;
+ el.style.height = `${screen.height}px`;
+ el.title = `${def.subtype} (from PDF)`;
+
+ // page.render() may or may not have produced a separate canvas for this
+ // annotation (hasOwnCanvas depends on the PDF's flags). When it did, use
+ // it as a pixel-faithful body; when it didn't, the appearance is on the
+ // main canvas already, so leave the box transparent โ it still captures
+ // clicks for select/delete.
+ const canvas = annotationCanvasMap.get(def.pdfjsId);
+ if (canvas) {
+ canvas.style.width = "100%";
+ canvas.style.height = "100%";
+ canvas.style.display = "block";
+ canvas.style.pointerEvents = "none";
+ el.appendChild(canvas);
+ }
+ return el;
+}
+
+// =============================================================================
+// Annotation CRUD
+// =============================================================================
+
+function addAnnotation(def: PdfAnnotationDef, skipUndo = false): void {
+ // Remove existing if same id (without pushing to undo)
+ removeAnnotation(def.id, true);
+ annotationMap.set(def.id, { def, elements: [] });
+ if (!skipUndo) {
+ pushEdit({ type: "add", id: def.id, before: null, after: { ...def } });
+ }
+ // Re-render if on current page
+ if (def.page === currentPage) {
+ renderAnnotationsForPage(currentPage);
+ }
+ updateAnnotationsBadge();
+ renderAnnotationPanel();
+}
+
+function updateAnnotation(
+ update: Partial & { id: string; type: string },
+ skipUndo = false,
+): void {
+ const tracked = annotationMap.get(update.id);
+ if (!tracked) return;
+
+ const before = { ...tracked.def } as PdfAnnotationDef;
+
+ // Merge partial update into existing def
+ const merged = { ...tracked.def, ...update } as PdfAnnotationDef;
+ tracked.def = merged;
+
+ if (!skipUndo) {
+ pushEdit({ type: "update", id: update.id, before, after: { ...merged } });
+ }
+
+ // Re-render if on current page
+ if (merged.page === currentPage) {
+ renderAnnotationsForPage(currentPage);
+ }
+ renderAnnotationPanel();
+}
+
+function removeAnnotation(id: string, skipUndo = false): void {
+ const tracked = annotationMap.get(id);
+ if (!tracked) return;
+ if (!skipUndo) {
+ pushEdit({ type: "remove", id, before: { ...tracked.def }, after: null });
+ }
+ for (const el of tracked.elements) el.remove();
+ annotationMap.delete(id);
+ selectedAnnotationIds.delete(id);
+ updateAnnotationsBadge();
+ renderAnnotationPanel();
+}
+// =============================================================================
+// Annotation Panel โ extracted to ./annotation-panel.ts
+// =============================================================================
+
+// =============================================================================
+// highlight_text Command
+// =============================================================================
+
+function handleHighlightText(cmd: {
+ id: string;
+ query: string;
+ page?: number;
+ color?: string;
+ content?: string;
+}): void {
+ const pagesToSearch: number[] = [];
+ if (cmd.page) {
+ pagesToSearch.push(cmd.page);
+ } else {
+ // Search all pages that have cached text
+ for (const [pageNum, text] of pageTextCache) {
+ if (text.toLowerCase().includes(cmd.query.toLowerCase())) {
+ pagesToSearch.push(pageNum);
+ }
+ }
+ }
+
+ let annotationIndex = 0;
+ for (const pageNum of pagesToSearch) {
+ // Find text positions using the text layer DOM if on current page,
+ // otherwise create approximate rects from text cache positions
+ const rects = findTextRects(cmd.query, pageNum);
+ if (rects.length > 0) {
+ const id =
+ pagesToSearch.length > 1
+ ? `${cmd.id}_p${pageNum}_${annotationIndex++}`
+ : cmd.id;
+ addAnnotation({
+ type: "highlight",
+ id,
+ page: pageNum,
+ rects,
+ color: cmd.color,
+ content: cmd.content,
+ });
+ }
+ }
+}
+
+/**
+ * Find text in a page and return PDF-coordinate rects.
+ * Uses the TextLayer DOM when the page is currently rendered,
+ * otherwise falls back to approximate character-based positioning.
+ */
+function findTextRects(query: string, pageNum: number): Rect[] {
+ if (pageNum !== currentPage) {
+ // For non-current pages, create approximate rects from page dimensions
+ // The text will be properly positioned when the user navigates to that page
+ return findTextRectsFromCache(query, pageNum);
+ }
+
+ // Use text layer DOM for current page
+ const spans = Array.from(
+ textLayerEl.querySelectorAll("span"),
+ ) as HTMLElement[];
+ if (spans.length === 0) return findTextRectsFromCache(query, pageNum);
+
+ const lowerQuery = query.toLowerCase();
+ const rects: Rect[] = [];
+ const wrapperEl = textLayerEl.parentElement!;
+ const wrapperRect = wrapperEl.getBoundingClientRect();
+
+ for (const span of spans) {
+ const text = span.textContent || "";
+ if (text.length === 0) continue;
+ const lowerText = text.toLowerCase();
+
+ let pos = 0;
+ while (true) {
+ const idx = lowerText.indexOf(lowerQuery, pos);
+ if (idx === -1) break;
+ pos = idx + 1;
+
+ const textNode = span.firstChild;
+ if (!textNode || textNode.nodeType !== Node.TEXT_NODE) continue;
+
+ try {
+ const range = document.createRange();
+ range.setStart(textNode, idx);
+ range.setEnd(textNode, Math.min(idx + lowerQuery.length, text.length));
+ const clientRects = range.getClientRects();
+
+ for (let ri = 0; ri < clientRects.length; ri++) {
+ const r = clientRects[ri];
+ // Convert screen coords back to PDF coords
+ const screenLeft = r.left - wrapperRect.left;
+ const screenTop = r.top - wrapperRect.top;
+ const pdfX = screenLeft / scale;
+ const pdfHeight = r.height / scale;
+ const pdfWidth = r.width / scale;
+ const pageHeight = parseFloat(annotationLayerEl.style.height) / scale;
+ const pdfY = pageHeight - (screenTop + r.height) / scale;
+ rects.push({
+ x: pdfX,
+ y: pdfY,
+ width: pdfWidth,
+ height: pdfHeight,
+ });
+ }
+ } catch {
+ // Range API errors with stale nodes
+ }
+ }
+ }
+
+ return rects;
+}
+
+function findTextRectsFromCache(query: string, pageNum: number): Rect[] {
+ const text = pageTextCache.get(pageNum);
+ if (!text) return [];
+ const lowerText = text.toLowerCase();
+ const lowerQuery = query.toLowerCase();
+ const idx = lowerText.indexOf(lowerQuery);
+ if (idx === -1) return [];
+
+ // Text exists in the cache but the text-layer DOM for this page isn't
+ // rendered yet โ we can't compute accurate rects. Returning a placeholder
+ // would persist wrong coordinates; return empty and let the caller skip.
+ return [];
+}
+
+// =============================================================================
+// get_pages โ Offscreen rendering for model analysis
+// =============================================================================
+
+const MAX_GET_PAGES = 20;
+const SCREENSHOT_MAX_DIM = 768; // Max pixel dimension for screenshots
+
+/**
+ * Expand intervals into a sorted deduplicated list of page numbers,
+ * clamped to [1, totalPages].
+ */
+function expandIntervals(
+ intervals: Array<{ start?: number; end?: number }>,
+): number[] {
+ const pages = new Set();
+ for (const iv of intervals) {
+ const s = Math.max(1, iv.start ?? 1);
+ const e = Math.min(totalPages, iv.end ?? totalPages);
+ for (let p = s; p <= e; p++) pages.add(p);
+ }
+ return [...pages].sort((a, b) => a - b);
+}
+
+/**
+ * Render a single page to an offscreen canvas and return base64 JPEG.
+ * Does not affect the visible canvas or text layer.
+ */
+async function renderPageOffscreen(pageNum: number): Promise {
+ if (!pdfDocument) throw new Error("No PDF loaded");
+ const page = await pdfDocument.getPage(pageNum);
+ const baseViewport = page.getViewport({ scale: 1.0 });
+
+ // Scale down to fit within SCREENSHOT_MAX_DIM
+ const maxDim = Math.max(baseViewport.width, baseViewport.height);
+ const renderScale =
+ maxDim > SCREENSHOT_MAX_DIM ? SCREENSHOT_MAX_DIM / maxDim : 1.0;
+ const viewport = page.getViewport({ scale: renderScale });
+
+ const canvas = document.createElement("canvas");
+ const dpr = 1; // No retina scaling for model screenshots
+ canvas.width = viewport.width * dpr;
+ canvas.height = viewport.height * dpr;
+ const ctx = canvas.getContext("2d")!;
+ ctx.scale(dpr, dpr);
+
+ // Render with ENABLE_STORAGE so filled form fields appear on the canvas
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ await (page.render as any)({
+ canvasContext: ctx,
+ viewport,
+ annotationMode: AnnotationMode.ENABLE_STORAGE,
+ annotationStorage: pdfDocument.annotationStorage,
+ }).promise;
+
+ // Paint annotations on top so the model can see them
+ paintAnnotationsOnCanvas(ctx, pageNum, {
+ width: viewport.width,
+ height: viewport.height,
+ scale: renderScale,
+ });
+
+ // Extract base64 JPEG (much smaller than PNG, well within body limits)
+ const dataUrl = canvas.toDataURL("image/jpeg", 0.85);
+ return dataUrl.split(",")[1];
+}
+
+/**
+ * Snapshot the live viewer for `interact({action:"get_viewer_state"})`.
+ *
+ * Selection is read from `window.getSelection()` at call time โ no caching;
+ * if the user navigated away or nothing is selected, `selection` is `null`.
+ * `boundingRect` is in model coords (PDF points, origin top-left, y-down) so
+ * it can be fed straight back into `add_annotations`.
+ */
+async function handleGetViewerState(requestId: string): Promise {
+ const CONTEXT_CHARS = 200;
+
+ let selection: {
+ text: string;
+ contextBefore: string;
+ contextAfter: string;
+ boundingRect: { x: number; y: number; width: number; height: number };
+ } | null = null;
+
+ const sel = window.getSelection();
+ const selectedText = sel?.toString().replace(/\s+/g, " ").trim();
+ if (sel && selectedText && sel.rangeCount > 0) {
+ // Only treat it as a PDF selection if it lives inside the text layer of
+ // the rendered page (not the toolbar, search box, etc.).
+ const range = sel.getRangeAt(0);
+ const anchor =
+ range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
+ ? (range.commonAncestorContainer as Element)
+ : range.commonAncestorContainer.parentElement;
+ if (anchor && textLayerEl.contains(anchor)) {
+ // Context: locate selection in the page's extracted text and slice
+ // ยฑCONTEXT_CHARS around it. Falls back to empty strings if fuzzy
+ // match fails (still return text + rect โ they're the load-bearing
+ // bits).
+ const pageText = pageTextCache.get(currentPage) ?? "";
+ const loc = findSelectionInText(pageText, selectedText);
+ const contextBefore = loc
+ ? pageText.slice(Math.max(0, loc.start - CONTEXT_CHARS), loc.start)
+ : "";
+ const contextAfter = loc
+ ? pageText.slice(loc.end, loc.end + CONTEXT_CHARS)
+ : "";
+
+ // Single bounding box, page-relative model coords. getBoundingClientRect
+ // is viewport-relative; subtract the page-wrapper origin then divide by
+ // scale โ PDF points (top-left origin, y-down โ matches the coord
+ // system documented in the interact tool description).
+ const r = range.getBoundingClientRect();
+ const origin = pageWrapperEl.getBoundingClientRect();
+ const round = (n: number) => Math.round(n * 100) / 100;
+ selection = {
+ text: selectedText,
+ contextBefore,
+ contextAfter,
+ boundingRect: {
+ x: round((r.left - origin.left) / scale),
+ y: round((r.top - origin.top) / scale),
+ width: round(r.width / scale),
+ height: round(r.height / scale),
+ },
+ };
+ }
+ }
+
+ const state = {
+ currentPage,
+ pageCount: totalPages,
+ zoom: Math.round(scale * 100),
+ displayMode: currentDisplayMode,
+ selectedAnnotationIds: [...selectedAnnotationIds],
+ selection,
+ };
+
+ await app.callServerTool({
+ name: "submit_viewer_state",
+ arguments: { requestId, state: JSON.stringify(state, null, 2) },
+ });
+}
+
+/**
+ * Collect text and/or screenshots for a set of page intervals.
+ * Shared by the server-driven `get_pages` command (via handleGetPages)
+ * and the app-registered `get_text` / `get_screenshot` tools.
+ */
+async function collectPageData(
+ intervals: Array<{ start?: number; end?: number }>,
+ getText: boolean,
+ getScreenshots: boolean,
+): Promise> {
+ const allPages = expandIntervals(intervals);
+ const pages = allPages.slice(0, MAX_GET_PAGES);
+
+ log.info(
+ `collectPageData: ${pages.length} pages (${pages[0]}..${pages[pages.length - 1]}), text=${getText}, screenshots=${getScreenshots}`,
+ );
+
+ const results: Array<{
+ page: number;
+ text?: string;
+ image?: string;
+ }> = [];
+
+ for (const pageNum of pages) {
+ const entry: { page: number; text?: string; image?: string } = {
+ page: pageNum,
+ };
+
+ if (getText) {
+ // Use cached text if available, otherwise extract on the fly
+ let text = pageTextCache.get(pageNum);
+ if (text == null && pdfDocument) {
+ try {
+ const pg = await pdfDocument.getPage(pageNum);
+ const tc = await pg.getTextContent();
+ text = (tc.items as Array<{ str?: string }>)
+ .map((item) => item.str || "")
+ .join(" ");
+ pageTextCache.set(pageNum, text);
+ } catch (err) {
+ log.error(
+ `collectPageData: text extraction failed for page ${pageNum}:`,
+ err,
+ );
+ text = "";
+ }
+ }
+ entry.text = text ?? "";
+ }
+
+ if (getScreenshots) {
+ try {
+ entry.image = await renderPageOffscreen(pageNum);
+ } catch (err) {
+ log.error(
+ `collectPageData: screenshot failed for page ${pageNum}:`,
+ err,
+ );
+ }
+ }
+
+ results.push(entry);
+ }
+
+ return results;
+}
+
+async function handleGetPages(cmd: {
+ requestId: string;
+ intervals: Array<{ start?: number; end?: number }>;
+ getText: boolean;
+ getScreenshots: boolean;
+}): Promise {
+ const results = await collectPageData(
+ cmd.intervals,
+ cmd.getText,
+ cmd.getScreenshots,
+ );
+
+ // Submit results back to server
+ try {
+ await app.callServerTool({
+ name: "submit_page_data",
+ arguments: { requestId: cmd.requestId, pages: results },
+ });
+ log.info(
+ `get_pages: submitted ${results.length} page(s) for ${cmd.requestId}`,
+ );
+ } catch (err) {
+ log.error("get_pages: failed to submit results:", err);
+ }
+}
+
+// =============================================================================
+// Annotation Persistence
+// =============================================================================
+
+/** Storage key for annotations โ uses toolInfo.id (available early) with viewUUID fallback */
+function annotationStorageKey(): string | null {
+ const toolId = app.getHostContext()?.toolInfo?.id;
+ if (toolId) return `pdf-annot:${toolId}`;
+ if (viewUUID) return `${viewUUID}:annotations`;
+ return null;
+}
+
+/**
+ * Import one page's native annotations into the baseline. Called lazily from
+ * renderPage() so we don't walk every page (and pull most of the file via
+ * range requests) before the user sees anything. Idempotent per page.
+ */
+function scanPageBaselineAnnotations(
+ pageNum: number,
+ annotations: unknown[],
+): void {
+ if (baselineScannedPages.has(pageNum)) return;
+ baselineScannedPages.add(pageNum);
+ let imported = 0;
+ for (let i = 0; i < annotations.length; i++) {
+ // Isolate each annotation: a malformed one must not bubble up to the
+ // caller's form-layer try in renderPage() (which would skip
+ // AnnotationLayer.render and hide form widgets for the whole page).
+ try {
+ const ann = annotations[i] as {
+ annotationType?: number;
+ subtype?: string;
+ name?: string;
+ rect?: number[];
+ };
+ const def = importPdfjsAnnotation(ann, pageNum, i);
+ if (def) {
+ pdfBaselineAnnotations.push(def);
+ imported++;
+ if (!annotationMap.has(def.id) && !restoredRemovedIds.has(def.id)) {
+ annotationMap.set(def.id, { def, elements: [] });
+ }
+ } else if (ann.annotationType !== 20) {
+ // Widget (type 20) is expected to be skipped; anything else we
+ // don't import will still be painted by page.render() onto the
+ // canvas as unselectable pixels. Log so we can diagnose
+ // "ghost annotations" (visible but not in panel, not clickable).
+ log.info(
+ `[WARN] Baseline: skipped PDF annotation on page ${pageNum}`,
+ `type=${ann.annotationType}`,
+ `subtype=${ann.subtype ?? "?"}`,
+ `name=${ann.name ?? "?"}`,
+ `rect=${ann.rect ? JSON.stringify(ann.rect) : "none"}`,
+ );
+ }
+ } catch (err) {
+ log.info(`Baseline: page ${pageNum} annotation import failed`, err);
+ }
+ }
+ if (imported > 0) {
+ try {
+ updateAnnotationsBadge();
+ renderAnnotationPanel();
+ } catch (err) {
+ log.info(`Baseline: page ${pageNum} panel update failed`, err);
+ }
+ }
+}
+
+function persistAnnotations(): void {
+ // Compute diff relative to PDF baseline
+ const currentAnnotations: PdfAnnotationDef[] = [];
+ for (const tracked of annotationMap.values()) {
+ currentAnnotations.push(tracked.def);
+ }
+ const diff = computeDiff(
+ pdfBaselineAnnotations,
+ currentAnnotations,
+ formFieldValues,
+ pdfBaselineFormValues,
+ );
+
+ // computeDiff only sees baseline ids from pages we've already scanned.
+ // Carry forward restored tombstones for unvisited pages so the first
+ // persist after restore doesn't drop them. Once every page is scanned the
+ // baseline is complete and computeDiff is authoritative on its own โ
+ // dropping the carry-forward then also stops a stale id (no longer in the
+ // file) from pinning dirty=true forever.
+ if (baselineScannedPages.size < totalPages) {
+ for (const id of restoredRemovedIds) {
+ if (!annotationMap.has(id) && !diff.removed.includes(id)) {
+ diff.removed.push(id);
+ }
+ }
+ }
+
+ // Dirty tracks whether there are unsaved changes. Undoing back to baseline
+ // yields an empty diff โ clean again โ save button disables.
+ if (!isRestoring) setDirty(!isDiffEmpty(diff));
+
+ const key = annotationStorageKey();
+ if (!key) return;
+ try {
+ localStorage.setItem(key, serializeDiff(diff));
+ } catch {
+ // localStorage may be full or unavailable
+ }
+}
+
+function restoreAnnotations(): void {
+ const key = annotationStorageKey();
+ if (!key) return;
+ isRestoring = true;
+ try {
+ const raw = localStorage.getItem(key);
+ if (!raw) return;
+
+ // Try new diff-based format first
+ const diff = deserializeDiff(raw);
+
+ // Merge baseline + diff. The loop below is add-only, so we MUST also
+ // delete: the per-page baseline scan re-seeds annotationMap with every
+ // native id it encounters โ including ones in diff.removed. Without the
+ // deletes here AND the restoredRemovedIds tombstones below, the zombie
+ // survives, and the next persistAnnotations() sees it in currentIds โ
+ // computeDiff produces removed=[] โ the deletion is permanently lost.
+ const merged = mergeAnnotations(pdfBaselineAnnotations, diff);
+ for (const def of merged) {
+ if (!annotationMap.has(def.id)) {
+ annotationMap.set(def.id, { def, elements: [] });
+ }
+ }
+ for (const id of diff.removed) {
+ annotationMap.delete(id);
+ // Tombstone so the lazy per-page baseline scan (which runs AFTER this
+ // restore) doesn't resurrect it.
+ restoredRemovedIds.add(id);
+ }
+
+ // Restore form fields
+ for (const [k, v] of Object.entries(diff.formFields)) {
+ formFieldValues.set(k, v);
+ }
+
+ // If we have user changes (diff is not empty), mark dirty
+ if (
+ diff.added.length > 0 ||
+ diff.removed.length > 0 ||
+ Object.keys(diff.formFields).length > 0
+ ) {
+ setDirty(true);
+ }
+ log.info(
+ `Restored ${annotationMap.size} annotations (${diff.added.length} added, ${diff.removed.length} removed), ${formFieldValues.size} form fields`,
+ );
+ } catch {
+ // Parse error or unavailable
+ } finally {
+ isRestoring = false;
+ }
+}
+
+// =============================================================================
+// PDF.js Form Field Name โ ID Mapping
+// =============================================================================
+
+/**
+ * Normalise a raw form field value into our string|boolean model.
+ * Returns null for empty/unfilled/button values so they don't clutter the
+ * panel or count as baseline.
+ *
+ * `type` is from getFieldObjects() (which knows field types); `raw` is
+ * preferably from page.getAnnotations().fieldValue (which is what the
+ * widget actually renders). A PDF can have the field-dict /V out of sync
+ * with the widget โ AnnotationLayer trusts the widget, so we must too.
+ */
+function normaliseFieldValue(
+ type: string | undefined,
+ raw: unknown,
+): string | boolean | null {
+ if (type === "button") return null;
+ // Checkbox/radio: fieldValue is the export string (e.g. "Yes"), "Off" = unset
+ if (type === "checkbox") {
+ return raw != null && raw !== "" && raw !== "Off" ? true : null;
+ }
+ if (type === "radiobutton") {
+ return raw != null && raw !== "" && raw !== "Off" ? String(raw) : null;
+ }
+ // Text/choice: fieldValue may be a string or an array of selections
+ if (Array.isArray(raw)) {
+ const joined = raw.filter(Boolean).join(", ");
+ return joined || null;
+ }
+ if (raw == null || raw === "") return null;
+ return String(raw);
+}
+
+/**
+ * Build mapping from field names (used by fill_form) to widget annotation IDs
+ * (used by annotationStorage).
+ *
+ * CRITICAL: getFieldObjects() returns field-dictionary IDs (the /T tree),
+ * but annotationStorage is keyed by WIDGET annotation IDs (what
+ * page.getAnnotations() returns). The two differ for PDFs where fields and
+ * their widget /Kids are separate objects. Using the wrong key makes all
+ * storage writes silently miss.
+ */
+async function buildFieldNameMap(
+ doc: pdfjsLib.PDFDocumentProxy,
+): Promise {
+ let pushedToStorage = false;
+ fieldNameToIds.clear();
+ radioButtonValues.clear();
+ fieldNameToPage.clear();
+ fieldNameToLabel.clear();
+ fieldNameToOrder.clear();
+ cachedFieldObjects = null;
+ pdfBaselineFormValues.clear();
+
+ // getFieldObjects() gives us types, current values (/V), and defaults (/DV).
+ // We DON'T use its .id โ that's the field dict ref, not the widget annot ref.
+ try {
+ cachedFieldObjects =
+ ((await doc.getFieldObjects()) as Record | null) ?? null;
+ } catch {
+ // getFieldObjects may fail on some PDFs
+ }
+
+ // No AcroForm โ nothing to map. Skip the per-page widget walk so form-free
+ // PDFs (the common large case) don't pull every page after first paint.
+ // getFieldObjects() itself only reads the catalog/AcroForm dict via range
+ // transport, so this gate is cheap.
+ if (!cachedFieldObjects || Object.keys(cachedFieldObjects).length === 0) {
+ return false;
+ }
+
+ // Scan every page's widget annotations to collect the CORRECT storage keys,
+ // plus labels, pages, positions, AND fieldValue (what the widget renders
+ // โ which can differ from getFieldObjects().value if the PDF is internally
+ // inconsistent, e.g. after a pdf-lib setText silently failed).
+ const fieldPositions: Array<{ name: string; page: number; y: number }> = [];
+ const widgetFieldValues = new Map();
+ for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
+ let annotations;
+ try {
+ const page = await doc.getPage(pageNum);
+ annotations = await page.getAnnotations();
+ } catch {
+ continue;
+ }
+ for (const ann of annotations) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const a = ann as any;
+ if (!a.fieldName || !a.id) continue;
+
+ // Widget annotation ID โ this is what annotationStorage keys by
+ const ids = fieldNameToIds.get(a.fieldName) ?? [];
+ ids.push(a.id);
+ fieldNameToIds.set(a.fieldName, ids);
+
+ // Radio buttons: pdf.js creates WITHOUT setting
+ // .value, so reading target.value gives the HTML default "on".
+ // Remember each widget's export value so the input listener can
+ // report it instead.
+ if (a.radioButton && a.buttonValue != null) {
+ radioButtonValues.set(a.id, String(a.buttonValue));
+ }
+
+ if (!fieldNameToPage.has(a.fieldName)) {
+ fieldNameToPage.set(a.fieldName, pageNum);
+ }
+ if (a.alternativeText) {
+ fieldNameToLabel.set(a.fieldName, a.alternativeText);
+ }
+ if (a.rect) {
+ fieldPositions.push({ name: a.fieldName, page: pageNum, y: a.rect[3] });
+ }
+ // Capture the value the widget will actually render. First widget wins
+ // (radio groups share the field's /V so they all match anyway).
+ if (!widgetFieldValues.has(a.fieldName) && a.fieldValue !== undefined) {
+ widgetFieldValues.set(a.fieldName, a.fieldValue);
+ }
+ }
+ }
+
+ // Ordering: page ascending, then Y descending (top-to-bottom on page)
+ fieldPositions.sort((a, b) => a.page - b.page || b.y - a.y);
+ const seen = new Set();
+ let idx = 0;
+ for (const fp of fieldPositions) {
+ if (!seen.has(fp.name)) {
+ seen.add(fp.name);
+ fieldNameToOrder.set(fp.name, idx++);
+ }
+ }
+
+ // Import baseline values AND remap cachedFieldObjects to widget IDs.
+ //
+ // Baseline: prefer the widget's fieldValue (what AnnotationLayer renders)
+ // over getFieldObjects().value. A PDF can have the field-dict /V out of
+ // sync with the widget โ if we import the field-dict value, the panel
+ // disagrees with what's on screen.
+ //
+ // Remap: pdf.js _bindResetFormAction (the PDF's in-document Reset button)
+ // iterates this structure, using .id to key storage and find DOM elements
+ // via [data-element-id=...]. Both use WIDGET ids. pdf-lib's save splits
+ // merged field+widget objects, so we rebuild with widget ids.
+ if (cachedFieldObjects) {
+ const remapped: Record = {};
+ for (const [name, fieldArr] of Object.entries(cachedFieldObjects)) {
+ const widgetIds = fieldNameToIds.get(name);
+ if (!widgetIds) continue; // no widget โ not rendered anyway
+
+ // Type comes from getFieldObjects (widget annot data doesn't have it).
+ // Value: prefer the AcroForm field-tree value over the widget's
+ // fieldValue. pdf-lib's save() can leave a page widget pointing at a
+ // stale /V while the field tree has the new one (seen with comb text
+ // fields), and getAnnotations() reads the widget. If the two disagree
+ // we push the field-tree value into annotationStorage so the rendered
+ // input matches what's actually in /AcroForm.
+ const type = fieldArr.find((f) => f.type)?.type;
+ const fieldTreeRaw = fieldArr.find((f) => f.value != null)?.value;
+ const widgetRaw = widgetFieldValues.get(name);
+ const raw = fieldTreeRaw ?? widgetRaw;
+ const v = normaliseFieldValue(type, raw);
+ if (v !== null) {
+ pdfBaselineFormValues.set(name, v);
+ // Seed current state from baseline so the panel shows it. A
+ // restored localStorage diff (applied in restoreAnnotations) will
+ // overwrite specific fields the user changed.
+ if (!formFieldValues.has(name)) formFieldValues.set(name, v);
+ // Widget out of sync with field tree โ force storage so
+ // AnnotationLayer renders the field-tree value, not the stale
+ // widget. (syncFormValuesToStorage skips baseline==current.)
+ if (fieldTreeRaw != null && fieldTreeRaw !== widgetRaw) {
+ setFieldInStorage(name, v);
+ pushedToStorage = true;
+ }
+ }
+
+ // Skip parent entries with no concrete id (radio groups: the /T tree
+ // has a parent with the export value, plus one child per widget).
+ const concrete = fieldArr.filter((f) => f.id && f.type);
+ remapped[name] = widgetIds.map((wid, i) => ({
+ ...(concrete[i] ?? concrete[0] ?? fieldArr[0]),
+ id: wid,
+ }));
+ }
+ cachedFieldObjects = remapped;
+ }
+
+ log.info(`Built field name map: ${fieldNameToIds.size} fields`);
+ return pushedToStorage;
+}
+
+/**
+ * Set one form field's value in pdf.js's annotationStorage, in the format
+ * AnnotationLayer expects to READ when it re-renders.
+ *
+ * Radio buttons need per-widget booleans: pdf.js's RadioButtonWidgetAnnotation
+ * render() has inverted string coercion (`value !== buttonValue` โ true for
+ * every NON-matching widget), so a string value on all widgets checks the
+ * first rendered one and clears the rest regardless of what you asked for.
+ * Match pdf.js's own change handler instead: `{value: true}` on the widget
+ * whose buttonValue matches, `{value: false}` on the siblings.
+ *
+ * Also patches the live DOM element for the current page so the user sees the
+ * change without waiting for a full re-render.
+ */
+function setFieldInStorage(name: string, value: string | boolean): void {
+ if (!pdfDocument) return;
+ const ids = fieldNameToIds.get(name);
+ if (!ids) return;
+ const storage = pdfDocument.annotationStorage;
+
+ // Radio group: at least one widget ID has a buttonValue recorded.
+ const isRadio = ids.some((id) => radioButtonValues.has(id));
+ if (isRadio) {
+ const want = String(value);
+ for (const id of ids) {
+ const checked = radioButtonValues.get(id) === want;
+ storage.setValue(id, { value: checked });
+ const el = formLayerEl.querySelector(
+ `input[data-element-id="${id}"]`,
+ ) as HTMLInputElement | null;
+ if (el) el.checked = checked;
+ }
+ return;
+ }
+
+ // Text / checkbox / select: same value on every widget (a field can have
+ // multiple widget annotations sharing one /V).
+ const storageValue = typeof value === "boolean" ? value : String(value);
+ for (const id of ids) {
+ storage.setValue(id, { value: storageValue });
+ const el = formLayerEl.querySelector(`[data-element-id="${id}"]`) as
+ | HTMLInputElement
+ | HTMLSelectElement
+ | HTMLTextAreaElement
+ | null;
+ if (!el) continue;
+ if (el instanceof HTMLInputElement && el.type === "checkbox") {
+ el.checked = !!value;
+ } else {
+ el.value = String(value);
+ }
+ }
+}
+
+/** Sync formFieldValues into pdfDocument.annotationStorage so AnnotationLayer renders pre-filled values.
+ * Skips values that match the PDF's baseline โ those are already in storage
+ * in pdf.js's native format (which may differ from our string/bool repr,
+ * e.g. checkbox stores "Yes" not `true`). Overwriting with our normalised
+ * form can break the Reset button's ability to restore defaults. */
+function syncFormValuesToStorage(): void {
+ if (!pdfDocument || fieldNameToIds.size === 0) return;
+ for (const [name, value] of formFieldValues) {
+ if (pdfBaselineFormValues.get(name) === value) continue;
+ setFieldInStorage(name, value);
+ }
+}
+
+// =============================================================================
+// PDF Save / Download with Annotations
+// =============================================================================
+
+/** Build annotated PDF bytes from the current state. */
+async function getAnnotatedPdfBytes(): Promise {
+ if (!pdfDocument) throw new Error("No PDF loaded");
+ const fullBytes = await pdfDocument.getData();
+
+ // Only export user-added annotations; baseline ones are already in the PDF
+ const annotations: PdfAnnotationDef[] = [];
+ const baselineIds = new Set(pdfBaselineAnnotations.map((a) => a.id));
+ for (const tracked of annotationMap.values()) {
+ if (!baselineIds.has(tracked.def.id)) {
+ annotations.push(tracked.def);
+ }
+ }
+
+ // Baseline annotations the user deleted: strip their refs from /Annots so
+ // they don't reappear on reload. Include restored tombstones for pages we
+ // haven't scanned yet โ those ids aren't in pdfBaselineAnnotations but the
+ // ref is still parseable from the id string. Ids without a recoverable ref
+ // (page-index fallback) can't be removed by-ref and are skipped.
+ const removedIds = new Set();
+ for (const a of pdfBaselineAnnotations) {
+ if (!annotationMap.has(a.id)) removedIds.add(a.id);
+ }
+ for (const id of restoredRemovedIds) {
+ if (!annotationMap.has(id)) removedIds.add(id);
+ }
+ const removedRefs = [...removedIds]
+ .map(parseAnnotationRef)
+ .filter((r): r is NonNullable => r !== null);
+
+ // Only write fields that actually changed vs. what's already in the PDF.
+ // Unchanged fields are no-ops at best, and at worst trip pdf-lib edge
+ // cases (max-length text, missing /Yes appearance, โฆ) on fields the user
+ // never touched โ which, before the per-field catch in
+ // buildAnnotatedPdfBytes, aborted every subsequent field.
+ //
+ // Fields the user cleared (present in baseline, absent from formFieldValues
+ // after clearAllItems()) still need an explicit "" / false so pdf-lib
+ // overwrites the original /V instead of leaving it intact.
+ const formFieldsOut = new Map();
+ for (const [name, value] of formFieldValues) {
+ if (pdfBaselineFormValues.get(name) !== value) {
+ formFieldsOut.set(name, value);
+ }
+ }
+ for (const [name, baselineValue] of pdfBaselineFormValues) {
+ if (!formFieldValues.has(name)) {
+ formFieldsOut.set(name, typeof baselineValue === "boolean" ? false : "");
+ }
+ }
+ return buildAnnotatedPdfBytes(
+ fullBytes as Uint8Array,
+ annotations,
+ formFieldsOut,
+ removedRefs,
+ );
+}
+
+async function savePdf(): Promise {
+ if (!pdfDocument || !isDirty || saveInProgress) return;
+
+ const fileName =
+ pdfUrl
+ .replace(/^(file|computer):\/\//, "")
+ .split(/[/\\]/)
+ .pop() || pdfUrl;
+ const choice = await showConfirmDialog(
+ "Save PDF",
+ "Overwrite this file with your annotations and form edits?",
+ [{ label: "Cancel" }, { label: "Save", primary: true }],
+ fileName,
+ );
+ if (choice !== 1) return;
+
+ saveInProgress = true;
+ saveBtn.disabled = true;
+ saveBtn.title = "Saving...";
+
+ try {
+ const pdfBytes = await getAnnotatedPdfBytes();
+ const base64 = uint8ArrayToBase64(pdfBytes);
+
+ const result = await app.callServerTool({
+ name: "save_pdf",
+ arguments: { url: pdfUrl, data: base64 },
+ });
+
+ if (result.isError) {
+ log.error("Save failed:", result.content);
+ saveBtn.disabled = false; // let user retry
+ } else {
+ log.info("PDF saved");
+ // Record mtime so we recognize our own write in file_changed
+ const sc = result.structuredContent as { mtimeMs?: number } | undefined;
+ lastSavedMtime = sc?.mtimeMs ?? null;
+
+ const key = annotationStorageKey();
+ if (key) {
+ try {
+ localStorage.removeItem(key);
+ } catch {
+ /* ignore */
+ }
+ }
+ // Reload from the bytes we just wrote. The previous approach (rebase
+ // baselines but keep the old pdfDocument) drifts: subsequent renders
+ // still rasterize stripped annotations from the old bytes, and the
+ // field/widget split that pdf-lib's save can create isn't reflected
+ // until reload anyway. Reload makes "what you see = what's on disk"
+ // an invariant. (file_changed echo is suppressed by lastSavedMtime.)
+ await reloadPdf();
+ }
+ } catch (err) {
+ log.error("Save failed:", err);
+ saveBtn.disabled = false;
+ } finally {
+ saveInProgress = false;
+ saveBtn.title = "Save to file (overwrites original)";
+ }
+}
+
+async function downloadAnnotatedPdf(): Promise {
+ if (!pdfDocument) return;
+ downloadBtn.disabled = true;
+ downloadBtn.title = "Preparing download...";
+
+ try {
+ const pdfBytes = await getAnnotatedPdfBytes();
+
+ const hasEdits = annotationMap.size > 0 || formFieldValues.size > 0;
+ const baseName = (pdfTitle || "document").replace(/\.pdf$/i, "");
+ const fileName = hasEdits ? `${baseName} - edited.pdf` : `${baseName}.pdf`;
+
+ const base64 = uint8ArrayToBase64(pdfBytes);
+
+ if (app.getHostCapabilities()?.downloadFile) {
+ const { isError } = await app.downloadFile({
+ contents: [
+ {
+ type: "resource",
+ resource: {
+ uri: `file:///${fileName}`,
+ mimeType: "application/pdf",
+ blob: base64,
+ },
+ },
+ ],
+ });
+ if (isError) {
+ log.info("Download was cancelled or denied by host");
+ }
+ } else {
+ // Fallback: create blob URL and trigger download
+ const blob = new Blob([pdfBytes.buffer as ArrayBuffer], {
+ type: "application/pdf",
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = fileName;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ }
+ } catch (err) {
+ log.error("Download error:", err);
+ } finally {
+ downloadBtn.disabled = false;
+ downloadBtn.title = "Download PDF";
+ }
+}
+
// Render state - prevents concurrent renders
let isRendering = false;
let pendingPage: number | null = null;
@@ -640,9 +3309,19 @@ async function renderPage() {
// Set display size in CSS pixels
canvasEl.style.width = `${viewport.width}px`;
canvasEl.style.height = `${viewport.height}px`;
+ // Drop any pinch preview transform in the same frame as the canvas
+ // resize so the size handoff is atomic.
+ pageWrapperEl.style.transform = "";
- // Scale context for retina
- ctx.scale(dpr, dpr);
+ // Retina: pass dpr via page.render's `transform` (NOT ctx.scale).
+ // pdf.js sizes per-annotation canvases as
+ // width = rectW * outputScaleX * viewport.scale
+ // and outputScaleX is read from transform[0] (defaults to 1). A bare
+ // ctx.scale(dpr,dpr) leaves outputScaleX at 1, so the
+ // annotationCanvasMap canvases get a half-sized backing store on
+ // retina while their internal setTransform IS dpr-aware โ the
+ // appearance renders 2ร too big into a 1ร buffer โ cropped/shifted.
+ const dprTransform = dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined;
// Clear and setup text layer
textLayerEl.innerHTML = "";
@@ -651,11 +3330,29 @@ async function renderPage() {
// Set --scale-factor so CSS font-size/transform rules work correctly.
textLayerEl.style.setProperty("--scale-factor", `${scale}`);
- // Render canvas - track the task so we can cancel it
+ // Render canvas - track the task so we can cancel it.
+ //
+ // annotationCanvasMap: pdf.js diverts annotations whose appearance needs
+ // its own bitmap (Stamp/Ink/FreeText/etc. with hasOwnCanvas) into
+ // per-id canvases instead of compositing onto the main canvas.
+ // renderImportedAnnotation() pulls from this map so those annotations
+ // become movable DOM elements with pixel-faithful visuals โ instead of
+ // unselectable canvas pixels (the old "ghost annotation" problem) or
+ // our lossy text-label re-render.
+ annotationCanvasMap.clear();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const renderTask = (page.render as any)({
canvasContext: ctx,
viewport,
+ transform: dprTransform,
+ annotationCanvasMap,
+ // isEditing forces hasOwnCanvas=true for stamps regardless of /F
+ // NoRotate (StampAnnotation.mustBeViewedWhenEditing in pdf.worker).
+ // Without this, stamps without NoRotate composite onto the main canvas
+ // and deleting the "imported" overlay leaves an unclickable pixel
+ // behind. Other markup types still gate on noRotate; for those the
+ // overlay stays a transparent click-box (delete is UI-only until save).
+ isEditing: true,
});
currentRenderTask = renderTask;
@@ -698,15 +3395,132 @@ async function renderPage() {
pageTextCache.set(pageToRender, items.join(""));
}
- // Size highlight layer to match canvas
+ // Size overlay layers to match canvas
highlightLayerEl.style.width = `${viewport.width}px`;
highlightLayerEl.style.height = `${viewport.height}px`;
+ annotationLayerEl.style.width = `${viewport.width}px`;
+ annotationLayerEl.style.height = `${viewport.height}px`;
+
+ // Render PDF.js AnnotationLayer for interactive form widgets
+ formLayerEl.innerHTML = "";
+ formLayerEl.style.width = `${viewport.width}px`;
+ formLayerEl.style.height = `${viewport.height}px`;
+ // Set CSS custom properties so AnnotationLayer font-size rules work correctly
+ formLayerEl.style.setProperty("--scale-factor", `${scale}`);
+ formLayerEl.style.setProperty("--total-scale-factor", `${scale}`);
+ try {
+ const annotations = await page.getAnnotations();
+ // Lazy baseline import โ piggyback on the annotations we just fetched
+ // for this page instead of walking all pages upfront.
+ scanPageBaselineAnnotations(pageToRender, annotations);
+ if (annotations.length > 0) {
+ const linkService = {
+ getDestinationHash: () => "#",
+ getAnchorUrl: () => "#",
+ addLinkAttributes: () => {},
+ isPageVisible: () => true,
+ isPageCached: () => true,
+ externalLinkEnabled: true,
+ };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const annotationLayer = new AnnotationLayer({
+ div: formLayerEl,
+ page,
+ viewport,
+ annotationStorage: pdfDocument.annotationStorage,
+ linkService,
+ accessibilityManager: null,
+ annotationCanvasMap: null,
+ annotationEditorUIManager: null,
+ structTreeLayer: null,
+ commentManager: null,
+ } as any);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ // Only feed Widgets (form fields) here. Markup annotations are
+ // owned by #annotation-layer; letting AnnotationLayer create
+ // elements for them in #form-layer adds invisible
+ // pointer-events:auto boxes that steal clicks from our overlays.
+ const widgetAnns = annotations.filter(
+ (a: { subtype?: string }) => a.subtype === "Widget",
+ );
+ await annotationLayer.render({
+ annotations: widgetAnns,
+ div: formLayerEl,
+ page,
+ viewport,
+ renderForms: true,
+ linkService,
+ annotationStorage: pdfDocument.annotationStorage,
+ fieldObjects: cachedFieldObjects,
+ } as any);
+
+ // Fix combo reset: pdf.js's resetform handler sets all
+ // option.selected = (option.value === defaultFieldValue), and
+ // defaultFieldValue is typically null โ nothing matches. On a
+ // non-multiple , the browser immediately normalizes the
+ // all-deselected state by auto-selecting the first option, so the
+ // combo shows "New York" instead of blank.
+ //
+ // We can't check state AFTER pdf.js's handler (normalisation has
+ // already happened), so we capture whether the select was blank
+ // BEFORE the event. If reset maps to no option, restore blankness.
+ for (const sel of formLayerEl.querySelectorAll(
+ "select:not([size])",
+ )) {
+ // data-default: exportValue the PDF's reset maps to ("" if none)
+ const defaultExport =
+ [...sel.options].find((o) => o.defaultSelected && o.value !== " ")
+ ?.value ?? "";
+ sel.addEventListener("resetform", () => {
+ // pdf.js's handler has already run (registered first). If the
+ // PDF's defaultFieldValue matched a real option, that option
+ // is now selected and we're done. Otherwise, all were
+ // deselected and the browser picked option[0].
+ if (defaultExport && sel.value === defaultExport) return;
+ // Re-insert a hidden blank and select it
+ for (const o of sel.querySelectorAll('option[value=" "]')) {
+ o.remove();
+ }
+ const blank = document.createElement("option");
+ blank.value = " ";
+ blank.hidden = true;
+ sel.prepend(blank);
+ sel.selectedIndex = 0;
+ const removeBlank = () => {
+ blank.remove();
+ sel.removeEventListener("input", removeBlank);
+ };
+ sel.addEventListener("input", removeBlank);
+ });
+ }
+
+ // Fix listbox font sizes: the default AnnotationLayer CSS uses
+ // a fixed 9px * scale-factor which can overflow when many options
+ // share a small PDF rect. Shrink font to fit.
+ for (const sel of formLayerEl.querySelectorAll(
+ "select[size]",
+ )) {
+ const size = sel.size || sel.options.length;
+ if (size > 1) {
+ const maxFontPx = sel.clientHeight / size - 2; // 2px for padding
+ if (maxFontPx > 0) {
+ sel.style.fontSize = `${maxFontPx}px`;
+ }
+ }
+ }
+ }
+ } catch (formErr) {
+ log.info("Form layer render skipped:", formErr);
+ }
// Re-render search highlights if search is active
if (searchOpen && searchQuery) {
renderHighlights();
}
+ // Re-render annotations for current page
+ renderAnnotationsForPage(pageToRender);
+
updateControls();
updatePageContext();
@@ -766,6 +3580,7 @@ function loadSavedPage(): number | null {
function goToPage(page: number) {
const targetPage = Math.max(1, Math.min(page, totalPages));
if (targetPage !== currentPage) {
+ selectAnnotation(null);
preloadPaused = true;
currentPage = targetPage;
saveCurrentPage();
@@ -782,19 +3597,45 @@ function nextPage() {
goToPage(currentPage + 1);
}
+function scrollSelectionIntoView(): void {
+ if (selectedAnnotationIds.size === 0) return;
+ // Use the first selected annotation's element
+ for (const id of selectedAnnotationIds) {
+ const tracked = annotationMap.get(id);
+ if (tracked && tracked.elements.length > 0) {
+ tracked.elements[0].scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ break;
+ }
+ }
+}
+
function zoomIn() {
- scale = Math.min(scale + 0.25, 3.0);
- renderPage();
+ userHasZoomed = true;
+ scale = Math.min(scale + 0.25, ZOOM_MAX);
+ renderPage().then(scrollSelectionIntoView);
}
function zoomOut() {
- scale = Math.max(scale - 0.25, 0.5);
- renderPage();
+ userHasZoomed = true;
+ // Intentionally NOT floored at fit-to-page (unlike pinch). Hosts may
+ // overlay UI on the iframe without reporting it in safeAreaInsets, so
+ // "fit" can leave the page bottom hidden; the button is the escape hatch
+ // to shrink past it. Pinch still rubber-bands at fit (see commitPinch).
+ scale = Math.max(scale - 0.25, ZOOM_MIN);
+ renderPage().then(scrollSelectionIntoView);
}
function resetZoom() {
- scale = 1.0;
- renderPage();
+ userHasZoomed = false;
+ // Re-fit rather than blindly snapping to 1.0 โ in a narrow inline iframe
+ // 1.0 overflows, and in fullscreen 1.0 leaves the page floating in space.
+ computeFitScale().then((fitScale) => {
+ scale = fitScale ?? 1.0;
+ renderPage().then(scrollSelectionIntoView);
+ });
}
async function toggleFullscreen() {
@@ -818,9 +3659,16 @@ async function toggleFullscreen() {
}
function updateFullscreenButton() {
- fullscreenBtn.textContent = currentDisplayMode === "fullscreen" ? "โถ" : "โถ";
- fullscreenBtn.title =
- currentDisplayMode === "fullscreen" ? "Exit fullscreen" : "Fullscreen";
+ const isFs = currentDisplayMode === "fullscreen";
+ const expandIcon = fullscreenBtn.querySelector(".expand-icon") as HTMLElement;
+ const collapseIcon = fullscreenBtn.querySelector(
+ ".collapse-icon",
+ ) as HTMLElement;
+ if (expandIcon) expandIcon.style.display = isFs ? "none" : "";
+ if (collapseIcon) collapseIcon.style.display = isFs ? "" : "none";
+ fullscreenBtn.title = isFs
+ ? "Exit fullscreen (Esc)"
+ : "Toggle fullscreen (โEnter)";
}
// Event listeners
@@ -833,6 +3681,111 @@ searchCloseBtn.addEventListener("click", closeSearch);
searchPrevBtn.addEventListener("click", goToPrevMatch);
searchNextBtn.addEventListener("click", goToNextMatch);
fullscreenBtn.addEventListener("click", toggleFullscreen);
+downloadBtn.addEventListener("click", downloadAnnotatedPdf);
+saveBtn.addEventListener("click", savePdf);
+
+// Sync user form input back to formFieldValues for persistence
+formLayerEl.addEventListener("input", (e) => {
+ const target = e.target as HTMLInputElement | HTMLSelectElement;
+ const fieldName = target.name;
+ if (!fieldName) return;
+ let value: string | boolean;
+ if (target instanceof HTMLInputElement && target.type === "checkbox") {
+ value = target.checked;
+ } else if (target instanceof HTMLInputElement && target.type === "radio") {
+ // pdf.js doesn't set .value on radio inputs โ target.value defaults to
+ // "on". Use the widget's export value (buttonValue) so the panel and
+ // baseline agree on the same representation.
+ if (!target.checked) return; // unchecking siblings โ ignore
+ const wid = target.getAttribute("data-element-id");
+ value = (wid && radioButtonValues.get(wid)) ?? target.value;
+ } else if (target instanceof HTMLSelectElement && target.multiple) {
+ // .value on a is only the first option; join them all
+ // so save can select() the full set on a PDFOptionList.
+ value = Array.from(target.selectedOptions, (o) => o.value).join(",");
+ } else {
+ value = target.value;
+ }
+ formFieldValues.set(fieldName, value);
+ updateAnnotationsBadge();
+ renderAnnotationPanel();
+ persistAnnotations();
+});
+
+// Track form field focus: deselect annotations + sync model context
+formLayerEl.addEventListener(
+ "focusin",
+ (e) => {
+ const target = e.target as HTMLInputElement | HTMLSelectElement;
+ const fieldName = target.name;
+ if (!fieldName) return;
+ // Focusing a form field deselects any selected annotations
+ if (selectedAnnotationIds.size > 0) {
+ selectAnnotation(null);
+ }
+ focusedFieldName = fieldName;
+ updatePageContext();
+ },
+ true,
+);
+
+// Handle form reset: PDF.js dispatches "resetform" on each field element
+formLayerEl.addEventListener(
+ "resetform",
+ (e) => {
+ const target = e.target as HTMLInputElement | HTMLSelectElement;
+ const fieldName = target?.name;
+ if (fieldName && formFieldValues.has(fieldName)) {
+ formFieldValues.delete(fieldName);
+ }
+ // Debounce the UI update since resetform fires per-element
+ if (!resetFormDebounceTimer) {
+ resetFormDebounceTimer = setTimeout(() => {
+ resetFormDebounceTimer = null;
+ updateAnnotationsBadge();
+ renderAnnotationPanel();
+ persistAnnotations();
+ }, 50);
+ }
+ },
+ true,
+);
+let resetFormDebounceTimer: ReturnType | null = null;
+
+// Clear focused field on blur
+formLayerEl.addEventListener(
+ "focusout",
+ () => {
+ if (focusedFieldName) {
+ focusedFieldName = null;
+ updatePageContext();
+ }
+ },
+ true,
+);
+
+initAnnotationPanel({
+ state: () => ({
+ currentPage,
+ isDirty,
+ pdfDocument,
+ pdfBaselineAnnotations,
+ cachedFieldObjects,
+ searchOpen,
+ }),
+ renderPage,
+ goToPage,
+ selectAnnotation,
+ persistAnnotations,
+ removeAnnotation,
+ requestFitToContent,
+ updatePageContext,
+ setFocusedField: (name) => {
+ focusedFieldName = name;
+ },
+ sendMessage: (msg) => app.sendMessage(msg),
+ getHostContext: () => app.getHostContext(),
+});
// Search input events
searchInputEl.addEventListener("input", () => {
@@ -871,8 +3824,134 @@ pageInputEl.addEventListener("keydown", (e) => {
}
});
+// Mousedown on text layer directly deselects annotations (catches cases where
+// annotation mousedown stopPropagation prevents bubbling to canvasContainerEl)
+textLayerEl.addEventListener("mousedown", () => {
+ if (selectedAnnotationIds.size > 0) selectAnnotation(null);
+ if (focusedFieldName) {
+ focusedFieldName = null;
+ updatePageContext();
+ }
+});
+
+// Click on empty area / text layer to deselect annotations and blur fields
+canvasContainerEl.addEventListener("mousedown", (e) => {
+ const target = e.target as HTMLElement;
+ // Deselect if clicking on container, canvas, page wrapper, or text layer content
+ if (
+ target === canvasContainerEl ||
+ target === canvasEl ||
+ target.classList?.contains("page-wrapper") ||
+ target.closest(".text-layer")
+ ) {
+ if (selectedAnnotationIds.size > 0) {
+ selectAnnotation(null);
+ }
+ if (focusedFieldName) {
+ focusedFieldName = null;
+ updatePageContext();
+ }
+ }
+});
+
// Keyboard navigation
document.addEventListener("keydown", (e) => {
+ // Delete/Backspace to delete selected annotations
+ if (
+ (e.key === "Delete" || e.key === "Backspace") &&
+ selectedAnnotationIds.size > 0
+ ) {
+ // Don't delete if user is typing in an input
+ if (
+ document.activeElement instanceof HTMLInputElement ||
+ document.activeElement instanceof HTMLTextAreaElement ||
+ document.activeElement instanceof HTMLSelectElement
+ ) {
+ return;
+ }
+ e.preventDefault();
+ const ids = [...selectedAnnotationIds];
+ selectAnnotation(null);
+ for (const id of ids) {
+ removeAnnotation(id);
+ }
+ persistAnnotations();
+ return;
+ }
+
+ // Ctrl/Cmd+Z: undo, Ctrl/Cmd+Shift+Z: redo
+ if ((e.ctrlKey || e.metaKey) && e.key === "z") {
+ // Don't intercept when typing in inputs
+ if (
+ document.activeElement instanceof HTMLInputElement ||
+ document.activeElement instanceof HTMLTextAreaElement
+ ) {
+ return;
+ }
+ e.preventDefault();
+ if (e.shiftKey) {
+ redo();
+ } else {
+ undo();
+ }
+ return;
+ }
+
+ // Ctrl/Cmd+C: copy selected annotations
+ if ((e.ctrlKey || e.metaKey) && e.key === "c" && !e.shiftKey) {
+ if (
+ document.activeElement instanceof HTMLInputElement ||
+ document.activeElement instanceof HTMLTextAreaElement
+ ) {
+ return;
+ }
+ if (selectedAnnotationIds.size > 0) {
+ e.preventDefault();
+ copySelectedAnnotations();
+ }
+ return;
+ }
+
+ // Ctrl/Cmd+X: cut selected annotations (copy + delete)
+ if ((e.ctrlKey || e.metaKey) && e.key === "x" && !e.shiftKey) {
+ if (
+ document.activeElement instanceof HTMLInputElement ||
+ document.activeElement instanceof HTMLTextAreaElement
+ ) {
+ return;
+ }
+ if (selectedAnnotationIds.size > 0) {
+ e.preventDefault();
+ copySelectedAnnotations().then((copied) => {
+ if (copied) {
+ const ids = [...selectedAnnotationIds];
+ selectAnnotation(null);
+ for (const id of ids) {
+ removeAnnotation(id);
+ }
+ persistAnnotations();
+ }
+ });
+ }
+ return;
+ }
+
+ // Ctrl/Cmd+S: save (for local files)
+ if ((e.ctrlKey || e.metaKey) && e.key === "s") {
+ e.preventDefault();
+ if (fileWritable && isDirty) {
+ savePdf();
+ }
+ return;
+ }
+
+ // Ctrl/Cmd+Enter: toggle fullscreen
+ if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
+ e.preventDefault();
+ toggleFullscreen();
+ return;
+ }
+
// Ctrl/Cmd+F: open our search if closed; if already focused, pass through to browser find
if ((e.ctrlKey || e.metaKey) && e.key === "f") {
if (!searchOpen) {
@@ -890,9 +3969,15 @@ document.addEventListener("keydown", (e) => {
return;
}
- // Don't handle nav shortcuts when search input is focused
+ // Don't handle nav shortcuts when an input element is focused
if (document.activeElement === searchInputEl) return;
if (document.activeElement === pageInputEl) return;
+ if (
+ document.activeElement instanceof HTMLInputElement ||
+ document.activeElement instanceof HTMLTextAreaElement ||
+ document.activeElement instanceof HTMLSelectElement
+ )
+ return;
// Ctrl/Cmd+0 to reset zoom
if ((e.ctrlKey || e.metaKey) && e.key === "0") {
@@ -903,7 +3988,10 @@ document.addEventListener("keydown", (e) => {
switch (e.key) {
case "Escape":
- if (searchOpen) {
+ if (selectedAnnotationIds.size > 0) {
+ selectAnnotation(null);
+ e.preventDefault();
+ } else if (searchOpen) {
closeSearch();
e.preventDefault();
} else if (currentDisplayMode === "fullscreen") {
@@ -941,6 +4029,13 @@ document.addEventListener("selectionchange", () => {
selectionUpdateTimeout = setTimeout(() => {
const sel = window.getSelection();
const text = sel?.toString().trim();
+ if (text) {
+ // Any text selection deselects annotations and blurs fields
+ if (selectedAnnotationIds.size > 0) selectAnnotation(null);
+ if (focusedFieldName) {
+ focusedFieldName = null;
+ }
+ }
if (text && text.length > 2) {
log.info("Selection changed:", text.slice(0, 50));
updatePageContext();
@@ -948,6 +4043,104 @@ document.addEventListener("selectionchange", () => {
}, 300);
});
+// --- Pinch zoom (fullscreen only) ---
+//
+// Covers two input paths:
+// 1. wheel + ctrlKey โ trackpad pinch on macOS Safari/Chrome/FF and
+// Windows precision touchpads. The browser synthesizes
+// these on pinch; deltaY < 0 is zoom-in.
+// 2. two-finger touch โ mobile Safari / Chrome Android. We track the
+// distance between the two touches and scale by the
+// ratio against the initial distance.
+//
+// Both paths apply a CSS transform to .page-wrapper for live feedback (GPU,
+// no canvas re-render per frame), then commit to a real renderPage() once
+// the gesture settles. renderPage() is heavy (PDF.js page.render โ canvas,
+// TextLayer, AnnotationLayer all rebuilt) โ way too slow for touchmove.
+
+/** Scale at gesture start. The CSS transform is relative to the rendered
+ * canvas, so previewScale / pinchStartScale is what we paint. */
+let pinchStartScale = 1.0;
+/** What we'd commit to if the gesture ended right now. */
+let previewScale = 1.0;
+/** Debounce timer โ wheel events have no end event, so we wait for quiet. */
+let pinchSettleTimer: ReturnType | null = null;
+/** computeFitScale() snapshot at gesture start (async โ may be null briefly). */
+let fitScaleAtPinchStart: number | null = null;
+/** Guards against firing toggleFullscreen() once per wheel event during a
+ * single inline pinch-in gesture. */
+let modeTransitionInFlight = false;
+
+function beginPinch() {
+ pinchStartScale = scale;
+ previewScale = scale;
+ // Seed synchronously when we can (at fit โ !userHasZoomed) so the very
+ // first updatePinch already has the right floor โ avoids a one-frame
+ // jitter when the async computeFitScale resolves mid-gesture.
+ fitScaleAtPinchStart = userHasZoomed ? null : scale;
+ void computeFitScale().then((s) => (fitScaleAtPinchStart = s));
+ // transform-origin matches the flex layout's anchor (justify-content:
+ // center, align-items: flex-start) so the preview and the committed
+ // canvas grow from the same point โ otherwise the page jumps on release.
+ pageWrapperEl.style.transformOrigin = "50% 0";
+}
+
+/** Fit-to-page floor for fullscreen (committed scale never goes below this).
+ * The preview is allowed to overshoot down to 0.75รfit for rubber-band
+ * feedback; release below 0.9รfit exits to inline, otherwise snaps to fit. */
+function pinchFitFloor(): number | null {
+ return currentDisplayMode === "fullscreen" ? fitScaleAtPinchStart : null;
+}
+
+function updatePinch(nextScale: number) {
+ const fit = pinchFitFloor();
+ // Rubber-band: preview may dip to 0.75รfit so the user sees the page pull
+ // away as they pinch out. Committed scale is clamped to fit in commitPinch.
+ const previewFloor = fit !== null ? fit * 0.75 : ZOOM_MIN;
+ previewScale = Math.min(ZOOM_MAX, Math.max(previewFloor, nextScale));
+ // Transform is RELATIVE to the rendered canvas (which sits at
+ // pinchStartScale), so a previewScale equal to pinchStartScale โ ratio 1.
+ pageWrapperEl.style.transform = `scale(${previewScale / pinchStartScale})`;
+ zoomLevelEl.textContent = `${Math.round(previewScale * 100)}%`;
+}
+
+function commitPinch() {
+ const fit = pinchFitFloor();
+ // Pinched out past fit (page visibly pulled away) โ exit fullscreen.
+ // Only when the gesture *started* near fit, so a single big pinch-out
+ // from deep zoom lands at fit instead of ejecting unexpectedly.
+ if (
+ fit !== null &&
+ pinchStartScale <= fit * 1.05 &&
+ previewScale < fit * 0.9
+ ) {
+ pageWrapperEl.style.transform = "";
+ userHasZoomed = false; // let refitScale() size the inline view
+ forceNextResizeRefit = true; // ResizeObserver inline path ignores shrinks
+ modeTransitionInFlight = true;
+ void toggleFullscreen().finally(() => {
+ setTimeout(() => (modeTransitionInFlight = false), 250);
+ });
+ return;
+ }
+ // Committed scale never below fit in fullscreen โ overshoot snaps back.
+ const target =
+ fit !== null
+ ? Math.max(fit, previewScale)
+ : Math.max(ZOOM_MIN, previewScale);
+ if (Math.abs(target - scale) < 0.01) {
+ // Snap-back / dead-zone โ no re-render needed.
+ pageWrapperEl.style.transform = "";
+ zoomLevelEl.textContent = `${Math.round(scale * 100)}%`;
+ return;
+ }
+ userHasZoomed = true;
+ scale = target;
+ // renderPage clears the transform in the same frame as the canvas
+ // resize (after its first await) so there's no snap-back.
+ renderPage().then(scrollSelectionIntoView);
+}
+
// Horizontal scroll/swipe to change pages (disabled when zoomed)
let horizontalScrollAccumulator = 0;
const SCROLL_THRESHOLD = 50;
@@ -957,13 +4150,55 @@ canvasContainerEl.addEventListener(
(event) => {
const e = event as WheelEvent;
+ // Trackpad pinch arrives as wheel with ctrlKey set (Chrome/FF/Edge on
+ // macOS+Windows, Safari on macOS). MUST check before the deltaX/deltaY
+ // comparison below โ pinch deltas come through on deltaY.
+ if (e.ctrlKey) {
+ e.preventDefault();
+ if (currentDisplayMode !== "fullscreen") {
+ // Inline: pinch-in (deltaY<0) is a request to go fullscreen.
+ // Pinch-out is ignored โ nothing smaller than inline.
+ if (e.deltaY < 0 && !modeTransitionInFlight) {
+ modeTransitionInFlight = true;
+ void toggleFullscreen().finally(() => {
+ // Hold the latch through the settle window so the tail of the
+ // gesture doesn't immediately start zooming the new fullscreen
+ // view (or, worse, re-toggle).
+ setTimeout(() => (modeTransitionInFlight = false), 250);
+ });
+ }
+ return;
+ }
+ if (modeTransitionInFlight) return; // swallow gesture tail post-toggle
+ if (pinchSettleTimer === null) beginPinch();
+ // exp(-deltaY * k) makes equal-magnitude in/out deltas inverse โ
+ // pinch out then back lands where you started. Clamp per event so a
+ // physical mouse wheel (deltaY โ ยฑ100/notch) doesn't slam to the
+ // limit; trackpad pinch deltas are ~ยฑ1-10 so the clamp is a no-op.
+ const d = Math.max(-25, Math.min(25, e.deltaY));
+ updatePinch(previewScale * Math.exp(-d * 0.01));
+ if (pinchSettleTimer) clearTimeout(pinchSettleTimer);
+ // 200ms โ slow trackpad pinches can leave >150ms gaps between wheel
+ // events, which would commit-then-restart and feel steppy.
+ pinchSettleTimer = setTimeout(() => {
+ pinchSettleTimer = null;
+ commitPinch();
+ }, 200);
+ return;
+ }
+
// Only intercept horizontal scroll, let vertical scroll through
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
- // When zoomed, let natural panning happen (no page changes)
- if (scale > 1.0) return;
+ // If the page overflows horizontally, let native panning handle it
+ // (no page changes). Checking actual overflow rather than `scale > 1.0`
+ // because fullscreen fit-scale is often >100% with the page still fully
+ // visible โ we want swipe-to-page there. +1 absorbs sub-pixel rounding.
+ if (canvasContainerEl.scrollWidth > canvasContainerEl.clientWidth + 1) {
+ return;
+ }
- // At 100% zoom, handle page navigation
+ // No horizontal overflow โ swipe changes pages.
e.preventDefault();
horizontalScrollAccumulator += e.deltaX;
if (horizontalScrollAccumulator > SCROLL_THRESHOLD) {
@@ -977,6 +4212,77 @@ canvasContainerEl.addEventListener(
{ passive: false },
);
+// Two-finger touch pinch. We listen on the container (not page-wrapper)
+// because the wrapper transforms during the gesture and would shift the
+// touch target out from under the fingers.
+let touchStartDist = 0;
+
+function touchDist(t: TouchList): number {
+ const dx = t[0].clientX - t[1].clientX;
+ const dy = t[0].clientY - t[1].clientY;
+ return Math.hypot(dx, dy);
+}
+
+canvasContainerEl.addEventListener(
+ "touchstart",
+ (event) => {
+ const e = event as TouchEvent;
+ if (e.touches.length !== 2) return;
+ // No preventDefault here โ keep iOS Safari happy. We block native
+ // pinch-zoom via touch-action CSS + preventDefault on touchmove.
+ touchStartDist = touchDist(e.touches);
+ if (touchStartDist > 0) beginPinch();
+ },
+ { passive: true },
+);
+
+canvasContainerEl.addEventListener(
+ "touchmove",
+ (event) => {
+ const e = event as TouchEvent;
+ if (e.touches.length !== 2 || touchStartDist === 0) return;
+ e.preventDefault(); // stop the browser zooming the whole viewport
+ const ratio = touchDist(e.touches) / touchStartDist;
+ if (currentDisplayMode !== "fullscreen") {
+ // Inline: a clear pinch-in means "go fullscreen". 1.15ร threshold
+ // avoids triggering on jittery two-finger taps/scrolls.
+ if (ratio > 1.15 && !modeTransitionInFlight) {
+ modeTransitionInFlight = true;
+ touchStartDist = 0; // end this gesture; fullscreen will refit
+ pageWrapperEl.style.transform = "";
+ void toggleFullscreen().finally(() => {
+ setTimeout(() => (modeTransitionInFlight = false), 250);
+ });
+ }
+ return;
+ }
+ updatePinch(pinchStartScale * ratio);
+ },
+ { passive: false },
+);
+
+canvasContainerEl.addEventListener("touchend", (event) => {
+ const e = event as TouchEvent;
+ // Gesture ends when we drop below two fingers. e.touches is the
+ // REMAINING set โ lifting one of two leaves length 1.
+ if (touchStartDist === 0 || e.touches.length >= 2) return;
+ touchStartDist = 0;
+ if (currentDisplayMode !== "fullscreen") {
+ // Inline pinch that didn't cross the threshold โ discard preview.
+ pageWrapperEl.style.transform = "";
+ return;
+ }
+ commitPinch();
+});
+
+canvasContainerEl.addEventListener("touchcancel", () => {
+ if (touchStartDist === 0) return;
+ touchStartDist = 0;
+ // Cancelled (call, app-switch) โ revert, don't commit a half-gesture.
+ pageWrapperEl.style.transform = "";
+ zoomLevelEl.textContent = `${Math.round(scale * 100)}%`;
+});
+
// Parse tool result
function parseToolResult(result: CallToolResult): {
url: string;
@@ -1021,6 +4327,7 @@ async function fetchChunk(
begin: number,
end: number,
): Promise {
+ const gen = loadGeneration; // capture before any await
const cacheKey = `${url}:${begin}-${end}`;
const cached = rangeCache.get(cacheKey);
if (cached) return cached;
@@ -1054,6 +4361,12 @@ async function fetchChunk(
bytes[i] = binaryString.charCodeAt(i);
}
+ // PDF was reloaded while this fetch was in flight โ don't poison the
+ // cache with bytes from the old generation's offsets.
+ if (gen !== loadGeneration) {
+ throw new Error("Fetch cancelled โ PDF was reloaded");
+ }
+
const entry: RangeResult = { bytes, totalBytes: chunk.totalBytes };
rangeCache.set(cacheKey, entry);
return entry;
@@ -1112,25 +4425,119 @@ async function fetchRange(
return entry;
}
+/**
+ * Reload the current PDF from disk, discarding all in-memory edits and caches.
+ * Preserves currentPage (clamped). Does not stop/restart the poll loop.
+ */
+async function reloadPdf(): Promise {
+ log.info("Reloading PDF from disk");
+ showLoading("Reloading...");
+
+ // Invalidate all in-flight fetches and the preloader
+ loadGeneration++;
+
+ // Drop byte cache โ file contents changed, everything is stale.
+ // In-flight requests will check loadGeneration before re-populating.
+ rangeCache.clear();
+ inflightRequests.clear();
+
+ // Cancel active render and destroy the old document
+ currentRenderTask?.cancel();
+ currentRenderTask = null;
+ const oldDoc = pdfDocument;
+ pdfDocument = null;
+ await oldDoc?.destroy().catch(() => {});
+
+ // Clear per-document edit/display state
+ for (const [, t] of annotationMap) for (const el of t.elements) el.remove();
+ annotationMap.clear();
+ formFieldValues.clear();
+ imageCache.clear();
+ selectedAnnotationIds.clear();
+ undoStack.length = 0;
+ redoStack.length = 0;
+ pdfBaselineAnnotations = [];
+ baselineScannedPages.clear();
+ restoredRemovedIds.clear();
+ pdfBaselineFormValues.clear();
+ pageTextCache.clear();
+ pageTextItemsCache.clear();
+ allMatches = [];
+ currentMatchIndex = -1;
+ focusedFieldName = null;
+ fieldNameToIds.clear();
+ radioButtonValues.clear();
+ fieldNameToLabel.clear();
+ fieldNameToOrder.clear();
+ cachedFieldObjects = null;
+
+ // Drop persisted localStorage diff โ disk is now the source of truth
+ const key = annotationStorageKey();
+ if (key) {
+ try {
+ localStorage.removeItem(key);
+ } catch {
+ /* ignore */
+ }
+ }
+
+ // Reset save-button state machine
+ saveBtnEverShown = false;
+ lastSavedMtime = null;
+ isDirty = false;
+ updateTitleDisplay();
+ updateSaveBtn();
+
+ // Reset preload indicators
+ pagesLoaded = 0;
+ preloadErrors = [];
+ loadingIndicatorEl.classList.remove("error");
+ loadingIndicatorEl.style.display = "none";
+
+ try {
+ const { document, totalBytes } = await loadPdfProgressively(pdfUrl);
+ pdfDocument = document;
+ totalPages = document.numPages;
+ currentPage = Math.max(1, Math.min(currentPage, totalPages));
+ log.info("PDF reloaded:", totalPages, "pages,", totalBytes, "bytes");
+
+ showViewer();
+ // Render immediately โ baseline-annotation scan now happens per-page
+ // inside renderPage(); buildFieldNameMap below early-returns when no
+ // AcroForm is present. See same pattern in the initial-load path.
+ await renderPage();
+
+ const seeded = await buildFieldNameMap(document);
+ syncFormValuesToStorage();
+ if (seeded) await renderPage();
+ updateAnnotationsBadge();
+ renderAnnotationPanel();
+
+ renderPage();
+ startPreloading();
+ } catch (err) {
+ log.error("Reload failed:", err);
+ showError(err instanceof Error ? err.message : String(err));
+ }
+}
+
/**
* Load PDF progressively using PDFDataRangeTransport.
* PDF.js will request ranges as needed to render pages.
*/
-async function loadPdfProgressively(
- urlToLoad: string,
- fileSizeBytes: number,
-): Promise<{
+async function loadPdfProgressively(urlToLoad: string): Promise<{
document: pdfjsLib.PDFDocumentProxy;
totalBytes: number;
}> {
- const fileTotalBytes = fileSizeBytes;
- log.info(`PDF file size: ${(fileTotalBytes / 1024) | 0} KB`);
-
class AppRangeTransport extends pdfjsLib.PDFDataRangeTransport {
requestDataRange(begin: number, end: number) {
fetchRange(urlToLoad, begin, end)
.then((result) => {
- this.onDataRange(begin, result.bytes);
+ // PDF.js transfers the ArrayBuffer to its worker, detaching it.
+ // Pass a copy so the rangeCache entry stays valid for re-requests
+ // (iOS/WKWebView re-requests ranges under memory pressure and
+ // throws "Buffer is already detached" on the cached original).
+ this.onDataRange(begin, result.bytes.slice());
})
.catch((err: unknown) => {
log.error(`Error fetching range ${begin}-${end}:`, err);
@@ -1138,10 +4545,29 @@ async function loadPdfProgressively(
}
}
+ // Probe current file size via a live read_pdf_bytes call. Don't trust the
+ // totalBytes from the display_pdf result: that's baked into conversation
+ // history, so if the user saved the PDF (annotations/form fields) and
+ // reloaded the conversation, the host replays a stale value. A mismatch
+ // makes pdf.js fail every chunk with an opaque "Bad end offset: N".
+ const { totalBytes: fileTotalBytes } = await fetchChunk(urlToLoad, 0, 1);
+ if (!Number.isInteger(fileTotalBytes) || fileTotalBytes <= 0) {
+ throw new Error(`Invalid totalBytes (${fileTotalBytes}) from server`);
+ }
+ log.info(`PDF file size: ${(fileTotalBytes / 1024) | 0} KB`);
+
// Create transport with total file size, no initial data โ PDF.js will request what it needs
const transport = new AppRangeTransport(fileTotalBytes, null);
- const loadingTask = pdfjsLib.getDocument({ range: transport });
+ const loadingTask = pdfjsLib.getDocument({
+ range: transport,
+ standardFontDataUrl: STANDARD_FONT_DATA_URL,
+ // Only fetch ranges renderPage()/getFieldObjects() actually ask for.
+ // Without these pdfjs background-prefetches the whole file regardless of
+ // the per-page lazy scans below.
+ disableAutoFetch: true,
+ disableStream: true,
+ });
try {
const document = await loadingTask.promise;
@@ -1201,16 +4627,25 @@ function scheduleSearchRefresh() {
async function startPreloading() {
if (!pdfDocument) return;
+ const gen = loadGeneration;
log.info("Starting background preload of", totalPages, "pages");
for (let i = 1; i <= totalPages; i++) {
+ if (gen !== loadGeneration) {
+ log.info("Preload aborted โ PDF reloaded");
+ return;
+ }
if (pageTextCache.has(i)) {
pagesLoaded++;
updateLoadingIndicator();
continue;
}
- // Yield to interactive navigation
- while (preloadPaused) await new Promise((r) => setTimeout(r, 50));
+ // Yield to interactive navigation. Re-check gen inside the wait so
+ // teardown doesn't spin here forever if it fires mid-pause.
+ while (preloadPaused && gen === loadGeneration) {
+ await new Promise((r) => setTimeout(r, 50));
+ }
+ if (gen !== loadGeneration) return;
try {
const page = await pdfDocument.getPage(i);
@@ -1250,6 +4685,10 @@ app.ontoolresult = async (result: CallToolResult) => {
// Note: pageCount may not be accurate until document loads
totalPages = parsed.pageCount || 1;
viewUUID = result._meta?.viewUUID ? String(result._meta.viewUUID) : undefined;
+ interactEnabled = result._meta?.interactEnabled === true;
+ fileWritable = result._meta?.writable === true;
+ // Debug bubble: server only emits _debug when --debug is set.
+ if (result._meta?._debug !== undefined) showDebugBubble(result._meta._debug);
// Restore saved page or use initial page
const savedPage = loadSavedPage();
@@ -1262,10 +4701,7 @@ app.ontoolresult = async (result: CallToolResult) => {
try {
// Use progressive loading - document available as soon as initial data arrives
- const { document, totalBytes } = await loadPdfProgressively(
- pdfUrl,
- parsed.totalBytes,
- );
+ const { document, totalBytes } = await loadPdfProgressively(pdfUrl);
pdfDocument = document;
totalPages = document.numPages;
@@ -1281,12 +4717,69 @@ app.ontoolresult = async (result: CallToolResult) => {
loadingIndicatorEl.style.display = "none";
showViewer();
+ downloadBtn.style.display = app.getHostCapabilities()?.downloadFile
+ ? ""
+ : "none";
+
+ // Compute fit + render IMMEDIATELY for fast first paint. The canvas is
+ // unsized until renderPage() runs โ anything async between showViewer()
+ // and here makes the empty viewer visible. The annotation/form scans
+ // below are O(numPages) and do NOT block the canvas (page.render only
+ // needs canvasContext+viewport), so they run after.
+ const fitScale = await computeFitScale();
+ if (fitScale !== null) {
+ scale = fitScale;
+ log.info("Initial fit scale:", scale);
+ }
+ // Restore any persisted user diff BEFORE first render so the per-page
+ // baseline scan inside renderPage() can honour the removed-id tombstones
+ // and not resurrect annotations the user deleted last session.
+ // restoreAnnotations is sync (localStorage read) so first paint is not
+ // delayed.
+ restoreAnnotations();
+ await renderPage();
+
+ // Build field name โ annotation ID mapping for form filling
+ const seeded = await buildFieldNameMap(document);
+ // Pre-populate annotationStorage from restored formFieldValues
+ syncFormValuesToStorage();
+ // buildFieldNameMap may have pushed AcroForm-tree values into storage
+ // (when the page widget's /V is stale vs the field dict โ pdf-lib's save
+ // can leave them split). The first renderPage above ran BEFORE that, so
+ // the form layer shows the stale widget value. Re-render so it picks up
+ // storage. Only when something was actually seeded โ most PDFs don't hit
+ // this and the extra render would be pure waste.
+ if (seeded) await renderPage();
+
+ updateAnnotationsBadge();
+ // Save button visibility driven by setDirty()/updateSaveBtn();
+ // restoreAnnotations() may have just flipped it via setDirty(true).
+ updateSaveBtn();
+
+ // Re-render to overlay PDF-baseline annotations + restored form values.
+ // For PDFs with neither, the canvas is identical โ no flicker.
renderPage();
// Start background preloading of all pages for text extraction
startPreloading();
+
+ // Start polling for commands now that we have viewUUID
+ if (viewUUID && interactEnabled) {
+ startPolling();
+ } else {
+ log.info("Interact disabled on server โ skipping poll_pdf_commands loop");
+ }
} catch (err) {
log.error("Error loading PDF:", err);
showError(err instanceof Error ? err.message : String(err));
+ // Poll anyway. The server's interact tool has no way to know we choked โ
+ // without a poll it waits 45s on every get_screenshot against this
+ // viewUUID. handleGetPages already null-guards pdfDocument, so a failed
+ // load just means empty page data โ server returns "No screenshot
+ // returned" (fast, actionable) instead of "Timeout waiting for page data
+ // from viewer" (slow, opaque).
+ if (viewUUID && interactEnabled) {
+ startPolling();
+ }
}
};
@@ -1295,6 +4788,305 @@ app.onerror = (err: unknown) => {
showError(err instanceof Error ? err.message : String(err));
};
+// =============================================================================
+// Command Queue Polling
+// =============================================================================
+
+// PdfCommand is the wire protocol between server and viewer.
+// Single source of truth in ./commands.ts โ adding a new command
+// variant there forces a matching `case` below.
+import type { PdfCommand } from "./commands.js";
+
+/** Get page height in PDF points (for coordinate conversion). */
+async function getPageHeight(pageNum: number): Promise {
+ if (!pdfDocument) return 792; // US Letter fallback
+ const page = await pdfDocument.getPage(pageNum);
+ return page.getViewport({ scale: 1.0 }).height;
+}
+
+/**
+ * Process a batch of commands from the server queue
+ */
+async function processCommands(commands: PdfCommand[]): Promise {
+ if (commands.length === 0) return;
+
+ for (const cmd of commands) {
+ log.info("Processing command:", cmd.type, cmd);
+ switch (cmd.type) {
+ case "navigate":
+ if (cmd.page >= 1 && cmd.page <= totalPages) {
+ goToPage(cmd.page);
+ }
+ break;
+ case "search":
+ openSearch();
+ searchInputEl.value = cmd.query;
+ performSearch(cmd.query);
+ break;
+ case "find":
+ performSilentSearch(cmd.query);
+ break;
+ case "search_navigate":
+ if (
+ allMatches.length > 0 &&
+ cmd.matchIndex >= 0 &&
+ cmd.matchIndex < allMatches.length
+ ) {
+ currentMatchIndex = cmd.matchIndex;
+ const match = allMatches[cmd.matchIndex];
+ if (match.pageNum !== currentPage) {
+ goToPage(match.pageNum);
+ }
+ renderHighlights();
+ updateSearchUI();
+ updatePageContext();
+ }
+ break;
+ case "zoom":
+ if (cmd.scale >= 0.5 && cmd.scale <= 3.0) {
+ scale = cmd.scale;
+ renderPage();
+ }
+ break;
+ case "add_annotations":
+ // Per-def isolation. If convertFromModelCoords or addAnnotation throws
+ // for one def (bad shape, NaN coords), the rest still apply โ and
+ // critically, a get_pages later in this batch still runs. Without
+ // this, a single bad annotation makes the whole batch throw out of
+ // processCommands, the iframe never reaches submit_page_data, and the
+ // server's interact() waits the full 45s for a reply that never comes.
+ for (const def of cmd.annotations) {
+ try {
+ const pageHeight = await getPageHeight(def.page);
+ addAnnotation(convertFromModelCoords(def, pageHeight));
+ } catch (err) {
+ log.error(`add_annotations: failed for id=${def.id}:`, err);
+ }
+ }
+ break;
+ case "update_annotations":
+ for (const update of cmd.annotations) {
+ const existing = annotationMap.get(update.id);
+ if (!existing) {
+ log.error(
+ `update_annotations: id=${update.id} not found โ skipping`,
+ );
+ continue;
+ }
+ try {
+ // The model sends model coords (y-down, y = top-left). existing.def
+ // is internal coords (y-up, y = bottom-left). For rect/circle/image
+ // the converted internal y = pageHeight - modelY - height โ a function
+ // of BOTH y AND height. If the model patches only {height}, we must
+ // still rewrite internal y to keep the top fixed; otherwise the
+ // bottom stays fixed and the top shifts. Same coupling applies to
+ // {page} changes across differently-sized pages.
+ //
+ // Fix: round-trip through model space. Convert existing to model
+ // coords, spread the patch on top (all-model now), convert back.
+ // convertToModelCoords is self-inverse (pdf-annotations.ts:192) so
+ // unchanged fields pass through unmolested.
+ const srcPageH = await getPageHeight(existing.def.page);
+ const existingModel = convertToModelCoords(existing.def, srcPageH);
+ const mergedModel = {
+ ...existingModel,
+ ...update,
+ } as PdfAnnotationDef;
+ const dstPageH =
+ update.page != null && update.page !== existing.def.page
+ ? await getPageHeight(update.page)
+ : srcPageH;
+ const mergedInternal = convertFromModelCoords(
+ mergedModel,
+ dstPageH,
+ );
+ // Pass the FULL merged def. updateAnnotation() already merges over
+ // the tracked def, so passing everything is correct and avoids the
+ // "only copy back Object.keys(update)" loop that caused the bug.
+ updateAnnotation(mergedInternal);
+ } catch (err) {
+ log.error(`update_annotations: failed for id=${update.id}:`, err);
+ }
+ }
+ break;
+ case "remove_annotations":
+ for (const id of cmd.ids) {
+ removeAnnotation(id);
+ }
+ // Re-render annotation layer since elements were removed
+ renderAnnotationsForPage(currentPage);
+ break;
+ case "highlight_text":
+ handleHighlightText(cmd);
+ break;
+ case "fill_form":
+ for (const field of cmd.fields) {
+ formFieldValues.set(field.name, field.value);
+ if (!fieldNameToIds.has(field.name)) {
+ log.info(`fill_form: no annotation IDs for field "${field.name}"`);
+ }
+ setFieldInStorage(field.name, field.value);
+ }
+ // Re-render to show updated form values (handles fields on other pages)
+ renderPage();
+ // Update sidebar badge and panel to reflect new form field values
+ updateAnnotationsBadge();
+ renderAnnotationPanel();
+ break;
+ case "get_pages":
+ // Await so the next poll doesn't start until submit_page_data has
+ // been SENT. The host (Claude Desktop/Nest) serializes iframeโserver
+ // tool calls โ if we re-poll immediately, submit_page_data queues
+ // behind the 30s long-poll and interact times out. Awaiting costs a
+ // few seconds of poll gap, but interact is blocked in waitForPageData
+ // anyway so no commands are lost.
+ try {
+ await handleGetPages(cmd);
+ } catch (err) {
+ log.error("get_pages failed โ submitting empty result:", err);
+ await app
+ .callServerTool({
+ name: "submit_page_data",
+ arguments: { requestId: cmd.requestId, pages: [] },
+ })
+ .catch(() => {});
+ }
+ break;
+ case "save_as":
+ // Same await-before-next-poll discipline as get_pages โ submit must
+ // be SENT before we re-poll, or it queues behind the 30s long-poll.
+ try {
+ const pdfBytes = await getAnnotatedPdfBytes();
+ const base64 = uint8ArrayToBase64(pdfBytes);
+ await app.callServerTool({
+ name: "submit_save_data",
+ arguments: { requestId: cmd.requestId, data: base64 },
+ });
+ log.info(`save_as: submitted ${pdfBytes.length} bytes`);
+ } catch (err) {
+ log.error("save_as: failed to build bytes โ submitting error:", err);
+ await app
+ .callServerTool({
+ name: "submit_save_data",
+ arguments: {
+ requestId: cmd.requestId,
+ error: err instanceof Error ? err.message : String(err),
+ },
+ })
+ .catch(() => {});
+ }
+ break;
+ case "get_viewer_state":
+ // Same await-before-next-poll discipline as get_pages/save_as.
+ try {
+ await handleGetViewerState(cmd.requestId);
+ } catch (err) {
+ log.error("get_viewer_state failed โ submitting error:", err);
+ await app
+ .callServerTool({
+ name: "submit_viewer_state",
+ arguments: {
+ requestId: cmd.requestId,
+ error: err instanceof Error ? err.message : String(err),
+ },
+ })
+ .catch(() => {});
+ }
+ break;
+ case "file_changed": {
+ // Skip our own save_pdf echo: either save is still in flight, or the
+ // event's mtime matches what save_pdf just returned.
+ if (saveInProgress) {
+ log.info("file_changed: save in progress, ignoring");
+ break;
+ }
+ if (
+ lastSavedMtime !== null &&
+ Math.abs(cmd.mtimeMs - lastSavedMtime) < 1
+ ) {
+ log.info("file_changed: matches our last save, ignoring");
+ lastSavedMtime = null; // one-shot
+ break;
+ }
+
+ if (!isDirty) {
+ await reloadPdf();
+ } else {
+ const choice = await showConfirmDialog(
+ "File changed on disk",
+ "The PDF was modified outside this viewer, but you have unsaved " +
+ "edits. Keeping your edits may cause rendering errors when " +
+ "scrolling to pages that haven't loaded yet.",
+ [
+ { label: "Discard & reload" },
+ { label: "Keep my edits", primary: true },
+ ],
+ );
+ if (choice === 0) {
+ await reloadPdf();
+ }
+ }
+ break;
+ }
+ }
+ }
+
+ // Persist after processing batch โ but only if anything mutated.
+ // get_pages / save_as / file_changed are read-only; writing localStorage
+ // and recomputing the diff for them is wasted work.
+ if (
+ commands.some(
+ (c) =>
+ c.type !== "get_pages" &&
+ c.type !== "save_as" &&
+ c.type !== "file_changed",
+ )
+ ) {
+ persistAnnotations();
+ }
+}
+
+let polling = false;
+
+function startPolling(): void {
+ if (polling) return;
+ polling = true;
+ pollLoop();
+}
+
+async function pollLoop(): Promise {
+ while (polling && viewUUID) {
+ try {
+ const result = await app.callServerTool({
+ name: "poll_pdf_commands",
+ arguments: { viewUUID },
+ });
+ if (result.isError) {
+ // Tool not found or server rejected โ stop polling entirely rather
+ // than spin on a non-recoverable error result (which doesn't throw).
+ log.error("poll_pdf_commands error โ stopping poll loop:", result);
+ polling = false;
+ return;
+ }
+ const commands =
+ (result.structuredContent as { commands?: PdfCommand[] })?.commands ||
+ [];
+ if (commands.length > 0) {
+ log.info(`Received ${commands.length} command(s)`);
+ await processCommands(commands);
+ }
+ } catch (err) {
+ log.error("Poll error:", err);
+ // Back off on error to avoid tight error loops
+ await new Promise((r) => setTimeout(r, 2000));
+ }
+ }
+}
+
+function stopPolling(): void {
+ polling = false;
+}
+
function handleHostContextChanged(ctx: McpUiHostContext) {
log.info("Host context changed:", ctx);
@@ -1308,41 +5100,786 @@ function handleHostContextChanged(ctx: McpUiHostContext) {
applyHostStyleVariables(ctx.styles.variables);
}
- // Apply safe area insets
+ // Apply safe area insets โ set CSS custom properties for use in both
+ // inline mode (padding on .main) and fullscreen mode (padding on .toolbar)
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 { top, right, bottom, left } = ctx.safeAreaInsets;
+ mainEl.style.setProperty("--safe-top", `${top}px`);
+ mainEl.style.setProperty("--safe-right", `${right}px`);
+ mainEl.style.setProperty("--safe-bottom", `${bottom}px`);
+ mainEl.style.setProperty("--safe-left", `${left}px`);
+ mainEl.style.paddingTop = `${top}px`;
+ mainEl.style.paddingRight = `${right}px`;
+ mainEl.style.paddingBottom = `${bottom}px`;
+ mainEl.style.paddingLeft = `${left}px`;
}
- // Log containerDimensions for debugging
- if (ctx.containerDimensions) {
- log.info("Container dimensions:", ctx.containerDimensions);
- }
-
- // Handle display mode changes
+ // Display-mode handling MUST run before the fit-to-width recompute below.
+ // Toggling .fullscreen flips `padding: 0 !important` on mainEl, which
+ // changes how much width the canvas-container actually gets. Measuring
+ // before the class lands sees the wrong padding.
if (ctx.displayMode) {
const wasFullscreen = currentDisplayMode === "fullscreen";
currentDisplayMode = ctx.displayMode as "inline" | "fullscreen";
const isFullscreen = currentDisplayMode === "fullscreen";
mainEl.classList.toggle("fullscreen", isFullscreen);
log.info(isFullscreen ? "Fullscreen mode enabled" : "Inline mode");
- // When exiting fullscreen, request resize to fit content
- if (wasFullscreen && !isFullscreen && pdfDocument) {
- requestFitToContent();
+ // Re-apply panel layout for new display mode
+ if (panelState.open) {
+ setAnnotationPanelOpen(true);
+ }
+ if (!isFullscreen) {
+ // Fullscreen zoom level is meaningless inline โ always refit on exit,
+ // however it was triggered (pinch, button, host Escape/ร).
+ userHasZoomed = false;
+ // The iframe shrink lands after this handler; let the ResizeObserver
+ // do one refit on that shrink (its inline branch normally ignores
+ // shrinks to avoid a requestFitToContent feedback loop).
+ forceNextResizeRefit = true;
+ }
+ if (wasFullscreen !== isFullscreen) {
+ // Fast-path refit (computeFitScale reads displayMode). The iframe may
+ // not have its final size yet โ the ResizeObserver one-shot above
+ // covers the inline-shrink case once it does.
+ void refitScale();
}
updateFullscreenButton();
}
+
+ // ResizeObserver on canvasContainerEl drives refit on actual size change;
+ // ctx.containerDimensions is logged for debugging but isn't load-bearing.
+ if (ctx.containerDimensions) {
+ log.info("Container dimensions changed:", ctx.containerDimensions);
+ }
}
+app.onteardown = async () => {
+ log.info("App is being torn down");
+ stopPolling();
+ // Bump loadGeneration so startPreloading's gen check fails and the
+ // loop exits on its next iteration (reuses the reload-abort mechanism).
+ loadGeneration++;
+ if (searchDebounceTimer) {
+ clearTimeout(searchDebounceTimer);
+ searchDebounceTimer = null;
+ }
+ if (pinchSettleTimer) {
+ clearTimeout(pinchSettleTimer);
+ pinchSettleTimer = null;
+ }
+ containerResizeObserver.disconnect();
+ return {};
+};
+
app.onhostcontextchanged = handleHostContextChanged;
+// =============================================================================
+// App-registered tools โ 1:1 with the server's `interact` commands.
+//
+// Each tool constructs the matching PdfCommand and dispatches through
+// processCommands(), so the command-handling logic lives in exactly one
+// place. The server-side `interact` tool remains for hosts that don't
+// support app-registered tools.
+// =============================================================================
+
+/** Shared zod shapes mirroring server.ts interact schema. */
+const FormFieldSchema = z.object({
+ name: z.string(),
+ value: z.union([z.string(), z.boolean()]),
+});
+const PageIntervalSchema = z.object({
+ start: z.number().min(1).optional(),
+ end: z.number().min(1).optional(),
+});
+
+/** Dispatch a command via processCommands and return a text result. */
+async function runCommand(
+ cmd: PdfCommand,
+ okText: string | (() => string),
+): Promise {
+ if (!pdfDocument) {
+ return {
+ content: [{ type: "text" as const, text: "Error: No document loaded" }],
+ isError: true,
+ };
+ }
+ await processCommands([cmd]);
+ const text = typeof okText === "function" ? okText() : okText;
+ return { content: [{ type: "text" as const, text }] };
+}
+
+app.registerTool(
+ "get-document-info",
+ {
+ title: "Get Document Info",
+ description:
+ "Get information about the current PDF document including title, current page, total pages, and zoom level",
+ },
+ async () => {
+ if (!pdfDocument) {
+ return {
+ content: [{ type: "text" as const, text: "Error: No document loaded" }],
+ isError: true,
+ };
+ }
+ const info = {
+ title: pdfTitle || "Untitled",
+ url: pdfUrl,
+ currentPage,
+ totalPages,
+ scale,
+ displayMode: currentDisplayMode,
+ };
+ return {
+ content: [{ type: "text" as const, text: JSON.stringify(info, null, 2) }],
+ structuredContent: info,
+ };
+ },
+);
+
+app.registerTool(
+ "navigate",
+ {
+ title: "Navigate",
+ description: "Jump to a specific page in the document",
+ inputSchema: z.object({
+ page: z.number().int().min(1).describe("Page number (1-indexed)"),
+ }),
+ },
+ async ({ page }) => {
+ if (pdfDocument && (page < 1 || page > totalPages)) {
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: `Error: Page ${page} out of range (1-${totalPages})`,
+ },
+ ],
+ isError: true,
+ };
+ }
+ return runCommand(
+ { type: "navigate", page },
+ `Navigated to page ${page}/${totalPages}`,
+ );
+ },
+);
+
+app.registerTool(
+ "search",
+ {
+ title: "Search",
+ description:
+ "Search for text and highlight matches in the viewer UI. Opens the search bar and jumps to the first match.",
+ inputSchema: z.object({
+ query: z.string().describe("Text to search for"),
+ }),
+ },
+ async ({ query }) =>
+ runCommand(
+ { type: "search", query },
+ () => `Searched for "${query}": ${allMatches.length} match(es)`,
+ ),
+);
+
+app.registerTool(
+ "find",
+ {
+ title: "Find",
+ description:
+ "Silent search โ locate matches without opening the search UI. Use before search_navigate.",
+ inputSchema: z.object({
+ query: z.string().describe("Text to search for"),
+ }),
+ },
+ async ({ query }) =>
+ runCommand(
+ { type: "find", query },
+ () => `Found ${allMatches.length} match(es) for "${query}"`,
+ ),
+);
+
+app.registerTool(
+ "search_navigate",
+ {
+ title: "Search Navigate",
+ description:
+ "Jump to the Nth search match (0-indexed). Call search or find first.",
+ inputSchema: z.object({
+ matchIndex: z.number().int().min(0).describe("Match index (0-indexed)"),
+ }),
+ },
+ async ({ matchIndex }) => {
+ if (allMatches.length === 0) {
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: "Error: No search results. Call search or find first.",
+ },
+ ],
+ isError: true,
+ };
+ }
+ if (matchIndex >= allMatches.length) {
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: `Error: matchIndex ${matchIndex} out of range (0-${allMatches.length - 1})`,
+ },
+ ],
+ isError: true,
+ };
+ }
+ return runCommand(
+ { type: "search_navigate", matchIndex },
+ `Jumped to match ${matchIndex + 1}/${allMatches.length} on page ${allMatches[matchIndex].pageNum}`,
+ );
+ },
+);
+
+app.registerTool(
+ "zoom",
+ {
+ title: "Zoom",
+ description: "Set the zoom scale for the document",
+ inputSchema: z.object({
+ scale: z
+ .number()
+ .min(0.5)
+ .max(3.0)
+ .describe("Zoom scale, 1.0 = 100% (range: 0.5-3.0)"),
+ }),
+ },
+ async ({ scale }) =>
+ runCommand(
+ { type: "zoom", scale },
+ `Zoom set to ${Math.round(scale * 100)}%`,
+ ),
+);
+
+app.registerTool(
+ "add_annotations",
+ {
+ title: "Add Annotations",
+ description:
+ "Add one or more annotations (highlight, note, rectangle, circle, line, stamp, image, freetext). Each needs id, type, page, and type-specific geometry.",
+ inputSchema: z.object({
+ annotations: z
+ .array(z.record(z.string(), z.any()))
+ .min(1)
+ .describe(
+ "Annotation objects. Each needs: id, type, page, plus type-specific fields (x, y, width, height, rects, color, content, etc.)",
+ ),
+ }),
+ },
+ async ({ annotations }) =>
+ runCommand(
+ {
+ type: "add_annotations",
+ annotations: annotations as PdfAnnotationDef[],
+ },
+ `Added ${annotations.length} annotation(s)`,
+ ),
+);
+
+app.registerTool(
+ "update_annotations",
+ {
+ title: "Update Annotations",
+ description:
+ "Patch existing annotations by id. Only id and type are required; other fields are merged.",
+ inputSchema: z.object({
+ annotations: z
+ .array(z.record(z.string(), z.any()))
+ .min(1)
+ .describe("Partial annotation objects. Each needs: id, type."),
+ }),
+ },
+ async ({ annotations }) =>
+ runCommand(
+ {
+ type: "update_annotations",
+ annotations: annotations as Extract<
+ PdfCommand,
+ { type: "update_annotations" }
+ >["annotations"],
+ },
+ `Updated ${annotations.length} annotation(s)`,
+ ),
+);
+
+app.registerTool(
+ "remove_annotations",
+ {
+ title: "Remove Annotations",
+ description: "Delete annotations by id",
+ inputSchema: z.object({
+ ids: z.array(z.string()).min(1).describe("Annotation IDs to remove"),
+ }),
+ },
+ async ({ ids }) =>
+ runCommand(
+ { type: "remove_annotations", ids },
+ `Removed ${ids.length} annotation(s)`,
+ ),
+);
+
+app.registerTool(
+ "highlight_text",
+ {
+ title: "Highlight Text",
+ description:
+ "Auto-locate text and add a highlight annotation. Searches the document (or a specific page) and highlights the first match.",
+ inputSchema: z.object({
+ query: z.string().describe("Text to locate and highlight"),
+ page: z
+ .number()
+ .int()
+ .min(1)
+ .optional()
+ .describe("Restrict search to this page"),
+ color: z.string().optional().describe("Highlight color (CSS color)"),
+ content: z.string().optional().describe("Tooltip/note content"),
+ }),
+ },
+ async ({ query, page, color, content }) => {
+ const id = `ht_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+ return runCommand(
+ { type: "highlight_text", id, query, page, color, content },
+ `Highlighted "${query}"${page ? ` on page ${page}` : ""} (id: ${id})`,
+ );
+ },
+);
+
+app.registerTool(
+ "fill_form",
+ {
+ title: "Fill Form",
+ description: "Fill PDF form fields by name",
+ inputSchema: z.object({
+ fields: z
+ .array(FormFieldSchema)
+ .min(1)
+ .describe(
+ "Form fields: { name, value } where value is string or boolean",
+ ),
+ }),
+ },
+ async ({ fields }) =>
+ runCommand(
+ { type: "fill_form", fields },
+ `Filled ${fields.length} field(s): ${fields.map((f) => f.name).join(", ")}`,
+ ),
+);
+
+app.registerTool(
+ "get_text",
+ {
+ title: "Get Text",
+ description:
+ "Extract text from one or more pages. Returns one text block per page.",
+ inputSchema: z.object({
+ page: z
+ .number()
+ .int()
+ .min(1)
+ .optional()
+ .describe("Single page (shorthand for intervals: [{start:N, end:N}])"),
+ intervals: z
+ .array(PageIntervalSchema)
+ .optional()
+ .describe(
+ "Page ranges. Each has optional start/end. [{start:1,end:5}], [{}] = all pages. Max 20 pages.",
+ ),
+ }),
+ },
+ async ({ page, intervals }) => {
+ if (!pdfDocument) {
+ return {
+ content: [{ type: "text" as const, text: "Error: No document loaded" }],
+ isError: true,
+ };
+ }
+ const resolved = intervals ?? (page ? [{ start: page, end: page }] : [{}]);
+ const data = await collectPageData(resolved, true, false);
+ const parts = data
+ .filter((e) => e.text != null)
+ .map((e) => ({
+ type: "text" as const,
+ text: `--- Page ${e.page} ---\n${e.text}`,
+ }));
+ return {
+ content:
+ parts.length > 0
+ ? parts
+ : [{ type: "text" as const, text: "No text content returned" }],
+ structuredContent: { pages: data },
+ };
+ },
+);
+
+app.registerTool(
+ "get_screenshot",
+ {
+ title: "Get Screenshot",
+ description: "Render a page to a JPEG image for visual analysis",
+ inputSchema: z.object({
+ page: z.number().int().min(1).describe("Page number to render"),
+ }),
+ },
+ async ({ page }) => {
+ if (!pdfDocument) {
+ return {
+ content: [{ type: "text" as const, text: "Error: No document loaded" }],
+ isError: true,
+ };
+ }
+ const data = await collectPageData(
+ [{ start: page, end: page }],
+ false,
+ true,
+ );
+ const entry = data[0];
+ if (entry?.image) {
+ return {
+ content: [
+ {
+ type: "image" as const,
+ data: entry.image,
+ mimeType: "image/jpeg",
+ },
+ ],
+ };
+ }
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: `Error: screenshot failed for page ${page}`,
+ },
+ ],
+ isError: true,
+ };
+ },
+);
+
// Connect to host
-app.connect().then(() => {
- log.info("Connected to host");
- const ctx = app.getHostContext();
- if (ctx) {
- handleHostContextChanged(ctx);
+app
+ .connect()
+ .then(() => {
+ log.info("Connected to host");
+ const ctx = app.getHostContext();
+ if (ctx) {
+ handleHostContextChanged(ctx);
+ }
+ // Restore annotations early using toolInfo.id (available before tool result)
+ restoreAnnotations();
+ updateAnnotationsBadge();
+ })
+ .catch((err: unknown) => {
+ // ui/initialize failed or transport rejected. Without a catch this is an
+ // unhandled rejection โ iframe shows blank, server times out on every
+ // interact call with no clue why.
+ log.error("Failed to connect to host:", err);
+ showError(
+ `Failed to connect to host: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ });
+
+// Debug helper: dump all annotation state. Run in DevTools console as
+// `__pdfDebug()` to diagnose ghost annotations (visible on canvas but not
+// in panel / not selectable). Returns a copy-pasteable JSON object.
+(window as unknown as { __pdfDebug: () => unknown }).__pdfDebug = () => {
+ const out = {
+ annotationMap: [...annotationMap.entries()].map(([id, t]) => ({
+ id,
+ type: t.def.type,
+ page: t.def.page,
+ hasElements: t.elements.length,
+ // Trim imageData โ can be megabytes of base64
+ def:
+ t.def.type === "image"
+ ? { ...t.def, imageData: t.def.imageData ? "" : undefined }
+ : t.def,
+ })),
+ pdfBaselineAnnotations: pdfBaselineAnnotations.map((d) => ({
+ id: d.id,
+ type: d.type,
+ page: d.page,
+ })),
+ annotationLayerChildren: [...annotationLayerEl.children].map((el) => ({
+ tag: el.tagName,
+ class: el.className,
+ })),
+ formLayerChildren: [...formLayerEl.children].map((el) => ({
+ tag: el.tagName,
+ class: el.className,
+ })),
+ localStorageKey: annotationStorageKey(),
+ localStorageRaw: (() => {
+ const k = annotationStorageKey();
+ if (!k) return null;
+ const raw = localStorage.getItem(k);
+ if (!raw) return null;
+ // Parse and trim imageData
+ try {
+ const d = JSON.parse(raw);
+ if (Array.isArray(d.added)) {
+ d.added = d.added.map((a: { imageData?: string }) =>
+ a.imageData ? { ...a, imageData: "" } : a,
+ );
+ }
+ return d;
+ } catch {
+ return { parseError: true, length: raw.length };
+ }
+ })(),
+ // PDF.js's own annotationStorage โ where editor stamps live.
+ // Keys like "pdfjs_internal_editor_*" are PDF.js's built-in annotation
+ // editor; those are invisible to our annotationMap tracking.
+ pdfJsAnnotationStorage: (() => {
+ if (!pdfDocument) return null;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const storage = pdfDocument.annotationStorage as any;
+ const all = storage.getAll?.() ?? storage._storage ?? new Map();
+ const entries =
+ all instanceof Map ? [...all.entries()] : Object.entries(all);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return entries.map(([k, v]: [string, any]) => ({
+ key: k,
+ ctor: v?.constructor?.name,
+ annotationType: v?.annotationType,
+ hasBitmap: Boolean(v?.bitmap),
+ value: v?.value,
+ }));
+ })(),
+ // All localStorage keys that look like ours โ per-tool-call keys mean
+ // old sessions' annotations won't restore under the current key.
+ allPdfAnnotKeys: Object.keys(localStorage).filter(
+ (k) => k.includes("pdf-annot") || k.includes(":annotations"),
+ ),
+ currentPage,
+ isDirty,
+ panelOpen: panelState.open,
+ };
+ console.log(JSON.stringify(out, null, 2));
+ // Also expose internals on window for interactive poking
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const w = window as any;
+ w.__pdf = { pdfDocument, annotationMap, annotationLayerEl, formLayerEl };
+ console.log(
+ "โ internals exposed as window.__pdf.{pdfDocument, annotationMap, ...}",
+ );
+ return out;
+};
+
+// =============================================================================
+// Image from File (shared by drag-drop and paste)
+// =============================================================================
+
+/**
+ * Create an image annotation from a File/Blob at the given screen position.
+ * If no position is given, places the image at the center of the current page.
+ */
+function addImageFromFile(
+ file: File | Blob,
+ screenX?: number,
+ screenY?: number,
+): void {
+ const reader = new FileReader();
+ reader.onload = () => {
+ const dataUrl = reader.result as string;
+ const base64 = dataUrl.split(",")[1];
+ const mimeType =
+ file.type || (base64.startsWith("/9j/") ? "image/jpeg" : "image/png");
+
+ const img = new Image();
+ img.onload = () => {
+ const maxWidth = 200; // PDF points
+ const aspectRatio = img.naturalHeight / img.naturalWidth;
+ const width = Math.min(img.naturalWidth, maxWidth);
+ const height = width * aspectRatio;
+
+ // Convert screen position to PDF internal coords, or default to page center
+ let pdfX: number;
+ let pdfInternalY: number;
+ if (screenX != null && screenY != null) {
+ pdfX = screenX / scale;
+ pdfInternalY = (containerHtmlEl.clientHeight - screenY) / scale;
+ } else {
+ // Center on the visible page area
+ const pageW = containerHtmlEl.clientWidth / scale;
+ const pageH = containerHtmlEl.clientHeight / scale;
+ pdfX = pageW / 2 - width / 2;
+ pdfInternalY = pageH / 2 + height / 2;
+ }
+
+ const id = `img_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+ const def: ImageAnnotation = {
+ type: "image",
+ id,
+ page: currentPage,
+ x: pdfX,
+ y: pdfInternalY,
+ width,
+ height,
+ imageData: base64,
+ mimeType,
+ };
+
+ // Downscale if base64 data is too large (> ~300KB)
+ if (base64.length > 400_000) {
+ const canvas = document.createElement("canvas");
+ const maxDim = 800;
+ let w = img.naturalWidth;
+ let h = img.naturalHeight;
+ if (w > maxDim || h > maxDim) {
+ const ratio = Math.min(maxDim / w, maxDim / h);
+ w = Math.round(w * ratio);
+ h = Math.round(h * ratio);
+ }
+ canvas.width = w;
+ canvas.height = h;
+ const ctx = canvas.getContext("2d")!;
+ ctx.drawImage(img, 0, 0, w, h);
+ const quality = mimeType === "image/jpeg" ? 0.7 : undefined;
+ const downscaledUrl = canvas.toDataURL(mimeType, quality);
+ def.imageData = downscaledUrl.split(",")[1];
+ }
+
+ addAnnotation(def);
+ selectAnnotation(def.id);
+ persistAnnotations();
+ };
+ img.src = dataUrl;
+ };
+ reader.readAsDataURL(file);
+}
+
+// =============================================================================
+// Image Drag & Drop
+// =============================================================================
+
+const containerHtmlEl = canvasContainerEl as HTMLElement;
+containerHtmlEl.addEventListener("dragover", (e: DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
+});
+
+containerHtmlEl.addEventListener("drop", async (e: DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (!e.dataTransfer?.files.length) return;
+
+ const containerRect = containerHtmlEl.getBoundingClientRect();
+ const dropX = e.clientX - containerRect.left;
+ const dropY = e.clientY - containerRect.top;
+
+ for (const file of e.dataTransfer.files) {
+ if (!file.type.startsWith("image/")) continue;
+ addImageFromFile(file, dropX, dropY);
}
});
+
+// =============================================================================
+// Clipboard: Copy / Cut / Paste
+// =============================================================================
+
+/** Clipboard format identifier so we can recognize our own data on paste. */
+const CLIPBOARD_FORMAT = "pdf-annotations/v1";
+
+/** Copy selected annotations to clipboard as JSON. Returns true if anything was copied. */
+async function copySelectedAnnotations(): Promise {
+ if (selectedAnnotationIds.size === 0) return false;
+ const defs: PdfAnnotationDef[] = [];
+ for (const id of selectedAnnotationIds) {
+ const tracked = annotationMap.get(id);
+ if (tracked) defs.push({ ...tracked.def });
+ }
+ if (defs.length === 0) return false;
+
+ const payload = JSON.stringify({
+ format: CLIPBOARD_FORMAT,
+ annotations: defs,
+ });
+ try {
+ await navigator.clipboard.writeText(payload);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/** Try to parse clipboard text as our annotation format. */
+function parseAnnotationClipboard(text: string): PdfAnnotationDef[] | null {
+ try {
+ const parsed = JSON.parse(text);
+ if (
+ parsed?.format === CLIPBOARD_FORMAT &&
+ Array.isArray(parsed.annotations)
+ ) {
+ return parsed.annotations;
+ }
+ } catch {
+ // Not our format
+ }
+ return null;
+}
+
+/** Paste annotations or images from clipboard. */
+function handlePaste(e: ClipboardEvent): void {
+ // Don't intercept paste in inputs
+ if (
+ document.activeElement instanceof HTMLInputElement ||
+ document.activeElement instanceof HTMLTextAreaElement ||
+ document.activeElement instanceof HTMLSelectElement
+ ) {
+ return;
+ }
+
+ const clipboardData = e.clipboardData;
+ if (!clipboardData) return;
+
+ // Check for image files first
+ for (const item of clipboardData.items) {
+ if (item.type.startsWith("image/")) {
+ e.preventDefault();
+ const file = item.getAsFile();
+ if (file) addImageFromFile(file);
+ return;
+ }
+ }
+
+ // Check for text that might be our annotation format
+ const text = clipboardData.getData("text/plain");
+ if (!text) return;
+
+ const annotations = parseAnnotationClipboard(text);
+ if (!annotations || annotations.length === 0) return;
+
+ e.preventDefault();
+
+ // Paste with new IDs and a slight offset so they don't overlap originals
+ const offset = 10; // PDF points
+ selectAnnotation(null);
+ for (const def of annotations) {
+ def.id = `paste_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+ def.page = currentPage;
+ if ("x" in def && typeof def.x === "number") def.x += offset;
+ if ("y" in def && typeof def.y === "number") def.y += offset;
+ if ("rects" in def && Array.isArray(def.rects)) {
+ for (const r of def.rects) {
+ r.x += offset;
+ r.y += offset;
+ }
+ }
+ addAnnotation(def);
+ selectAnnotation(def.id, true);
+ }
+ persistAnnotations();
+}
+
+document.addEventListener("paste", handlePaste);
diff --git a/examples/pdf-server/src/pdf-annotations.test.ts b/examples/pdf-server/src/pdf-annotations.test.ts
new file mode 100644
index 0000000..40e63ff
--- /dev/null
+++ b/examples/pdf-server/src/pdf-annotations.test.ts
@@ -0,0 +1,1886 @@
+import { describe, it, expect, beforeAll } from "bun:test";
+import {
+ emptyDiff,
+ isDiffEmpty,
+ serializeDiff,
+ deserializeDiff,
+ mergeAnnotations,
+ computeDiff,
+ cssColorToRgb,
+ defaultColor,
+ importPdfjsAnnotation,
+ buildAnnotatedPdfBytes,
+ parseAnnotationRef,
+ base64ToUint8Array,
+ uint8ArrayToBase64,
+ convertFromModelCoords,
+ convertToModelCoords,
+ type PdfAnnotationDef,
+ type AnnotationDiff,
+} from "./pdf-annotations";
+import {
+ PDFDocument,
+ PDFDict,
+ PDFName,
+ PDFArray,
+ PDFNumber,
+} from "@cantoo/pdf-lib";
+
+// =============================================================================
+// Diff Model
+// =============================================================================
+
+describe("AnnotationDiff model", () => {
+ describe("emptyDiff", () => {
+ it("creates a diff with no changes", () => {
+ const diff = emptyDiff();
+ expect(diff.added).toEqual([]);
+ expect(diff.removed).toEqual([]);
+ expect(diff.formFields).toEqual({});
+ });
+ });
+
+ describe("isDiffEmpty", () => {
+ it("returns true for empty diff", () => {
+ expect(isDiffEmpty(emptyDiff())).toBe(true);
+ });
+
+ it("returns false when there are added annotations", () => {
+ const diff = emptyDiff();
+ diff.added.push({
+ type: "note",
+ id: "n1",
+ page: 1,
+ x: 100,
+ y: 200,
+ content: "test",
+ });
+ expect(isDiffEmpty(diff)).toBe(false);
+ });
+
+ it("returns false when there are removed annotations", () => {
+ const diff = emptyDiff();
+ diff.removed.push("pdf-5-0");
+ expect(isDiffEmpty(diff)).toBe(false);
+ });
+
+ it("returns false when there are form field values", () => {
+ const diff = emptyDiff();
+ diff.formFields["name"] = "John";
+ expect(isDiffEmpty(diff)).toBe(false);
+ });
+ });
+
+ describe("serializeDiff / deserializeDiff", () => {
+ it("round-trips an empty diff", () => {
+ const diff = emptyDiff();
+ const json = serializeDiff(diff);
+ const restored = deserializeDiff(json);
+ expect(restored).toEqual(diff);
+ });
+
+ it("round-trips a diff with all fields populated", () => {
+ const diff: AnnotationDiff = {
+ added: [
+ {
+ type: "highlight",
+ id: "h1",
+ page: 1,
+ rects: [{ x: 72, y: 700, width: 200, height: 12 }],
+ color: "#ff0000",
+ },
+ ],
+ removed: ["pdf-5-0", "pdf-8-0"],
+ formFields: { name: "Alice", agree: true },
+ };
+ const json = serializeDiff(diff);
+ const restored = deserializeDiff(json);
+ expect(restored).toEqual(diff);
+ });
+
+ it("returns empty diff for invalid JSON", () => {
+ expect(deserializeDiff("not json")).toEqual(emptyDiff());
+ });
+
+ it("returns empty diff for JSON with wrong structure", () => {
+ expect(deserializeDiff('{"foo": "bar"}')).toEqual(emptyDiff());
+ });
+
+ it("handles missing fields gracefully", () => {
+ const result = deserializeDiff('{"added": []}');
+ expect(result.added).toEqual([]);
+ expect(result.removed).toEqual([]);
+ expect(result.formFields).toEqual({});
+ });
+ });
+});
+
+// =============================================================================
+// Merge Logic
+// =============================================================================
+
+describe("mergeAnnotations", () => {
+ const pdfNote: PdfAnnotationDef = {
+ type: "note",
+ id: "pdf-5-0",
+ page: 1,
+ x: 100,
+ y: 200,
+ content: "Original note",
+ };
+
+ const pdfHighlight: PdfAnnotationDef = {
+ type: "highlight",
+ id: "pdf-8-0",
+ page: 1,
+ rects: [{ x: 72, y: 700, width: 200, height: 12 }],
+ };
+
+ const userStamp: PdfAnnotationDef = {
+ type: "stamp",
+ id: "s1",
+ page: 1,
+ x: 300,
+ y: 400,
+ label: "APPROVED",
+ };
+
+ it("returns PDF annotations unchanged when diff is empty", () => {
+ const merged = mergeAnnotations([pdfNote, pdfHighlight], emptyDiff());
+ expect(merged).toEqual([pdfNote, pdfHighlight]);
+ });
+
+ it("filters out removed PDF annotations", () => {
+ const diff: AnnotationDiff = {
+ added: [],
+ removed: ["pdf-5-0"],
+ formFields: {},
+ };
+ const merged = mergeAnnotations([pdfNote, pdfHighlight], diff);
+ expect(merged).toEqual([pdfHighlight]);
+ });
+
+ it("includes added annotations", () => {
+ const diff: AnnotationDiff = {
+ added: [userStamp],
+ removed: [],
+ formFields: {},
+ };
+ const merged = mergeAnnotations([pdfNote], diff);
+ expect(merged).toHaveLength(2);
+ expect(merged).toContainEqual(pdfNote);
+ expect(merged).toContainEqual(userStamp);
+ });
+
+ it("added annotations with same ID as PDF annotation override the PDF one", () => {
+ const modifiedNote: PdfAnnotationDef = {
+ ...pdfNote,
+ content: "Modified note",
+ };
+ const diff: AnnotationDiff = {
+ added: [modifiedNote],
+ removed: [],
+ formFields: {},
+ };
+ const merged = mergeAnnotations([pdfNote, pdfHighlight], diff);
+ expect(merged).toHaveLength(2);
+ const note = merged.find((a) => a.id === "pdf-5-0");
+ expect(note).toBeDefined();
+ expect((note as any).content).toBe("Modified note");
+ });
+
+ it("handles both additions and removals", () => {
+ const diff: AnnotationDiff = {
+ added: [userStamp],
+ removed: ["pdf-5-0"],
+ formFields: {},
+ };
+ const merged = mergeAnnotations([pdfNote, pdfHighlight], diff);
+ expect(merged).toHaveLength(2);
+ expect(merged.map((a) => a.id).sort()).toEqual(["pdf-8-0", "s1"]);
+ });
+
+ it("returns only added annotations when all PDF annotations are removed", () => {
+ const diff: AnnotationDiff = {
+ added: [userStamp],
+ removed: ["pdf-5-0", "pdf-8-0"],
+ formFields: {},
+ };
+ const merged = mergeAnnotations([pdfNote, pdfHighlight], diff);
+ expect(merged).toEqual([userStamp]);
+ });
+
+ it("handles empty PDF annotations with additions", () => {
+ const diff: AnnotationDiff = {
+ added: [userStamp],
+ removed: [],
+ formFields: {},
+ };
+ const merged = mergeAnnotations([], diff);
+ expect(merged).toEqual([userStamp]);
+ });
+});
+
+// =============================================================================
+// computeDiff
+// =============================================================================
+
+describe("computeDiff", () => {
+ const pdfNote: PdfAnnotationDef = {
+ type: "note",
+ id: "pdf-5-0",
+ page: 1,
+ x: 100,
+ y: 200,
+ content: "Original",
+ };
+ const pdfHighlight: PdfAnnotationDef = {
+ type: "highlight",
+ id: "pdf-8-0",
+ page: 1,
+ rects: [{ x: 72, y: 700, width: 200, height: 12 }],
+ };
+
+ it("produces empty diff when nothing changed", () => {
+ const diff = computeDiff(
+ [pdfNote, pdfHighlight],
+ [pdfNote, pdfHighlight],
+ new Map(),
+ );
+ expect(diff.added).toEqual([]);
+ expect(diff.removed).toEqual([]);
+ expect(diff.formFields).toEqual({});
+ });
+
+ it("detects added annotations", () => {
+ const userStamp: PdfAnnotationDef = {
+ type: "stamp",
+ id: "s1",
+ page: 1,
+ x: 300,
+ y: 400,
+ label: "DRAFT",
+ };
+ const diff = computeDiff([pdfNote], [pdfNote, userStamp], new Map());
+ expect(diff.added).toEqual([userStamp]);
+ expect(diff.removed).toEqual([]);
+ });
+
+ it("detects removed annotations", () => {
+ const diff = computeDiff(
+ [pdfNote, pdfHighlight],
+ [pdfHighlight],
+ new Map(),
+ );
+ expect(diff.removed).toEqual(["pdf-5-0"]);
+ expect(diff.added).toEqual([]);
+ });
+
+ it("captures modification of a USER-ADDED annotation (id not in baseline)", () => {
+ // User adds a stamp, then edits its label. The stamp's id was never
+ // in the baseline โ it stays in `added` with its latest content.
+ const editedStamp: PdfAnnotationDef = {
+ type: "stamp",
+ id: "user-s1",
+ page: 1,
+ x: 300,
+ y: 400,
+ label: "FINAL", // was "DRAFT" originally, now edited
+ };
+ const diff = computeDiff([pdfNote], [pdfNote, editedStamp], new Map());
+ expect(diff.added).toEqual([editedStamp]);
+ expect(diff.added[0]).toMatchObject({ label: "FINAL" });
+ });
+
+ it("KNOWN LIMITATION: in-place edit of a baseline annotation is lost", () => {
+ // Editing a PDF-native note's content: same id as baseline, different
+ // content. computeDiff is id-set based โ same-id โ neither added nor
+ // removed. The edit vanishes on reload.
+ //
+ // Viewer mitigation: addAnnotation() always removeAnnotation(id) first,
+ // so the common UI path is remove+add. But updateAnnotation() mutates
+ // in place โ if the interact tool's update_annotations edits a baseline
+ // annotation, that edit won't survive a page reload.
+ const editedNote: PdfAnnotationDef = {
+ ...pdfNote,
+ content: "Edited by user",
+ };
+ const diff = computeDiff(
+ [pdfNote, pdfHighlight],
+ [editedNote, pdfHighlight],
+ new Map(),
+ );
+ expect(diff.added).toEqual([]);
+ expect(diff.removed).toEqual([]);
+ // If this starts FAILING, the limitation was fixed โ update expectations.
+ });
+
+ it("captures form field values", () => {
+ const fields = new Map([
+ ["name", "Alice"],
+ ["agree", true],
+ ]);
+ const diff = computeDiff([pdfNote], [pdfNote], fields);
+ expect(diff.formFields).toEqual({ name: "Alice", agree: true });
+ });
+
+ it("omits form fields matching baseline", () => {
+ const baseline = new Map([
+ ["name", "Alice"],
+ ["agree", true],
+ ]);
+ const fields = new Map([
+ ["name", "Alice"], // unchanged
+ ["agree", false], // changed
+ ["email", "a@b"], // new
+ ]);
+ const diff = computeDiff([], [], fields, baseline);
+ expect(diff.formFields).toEqual({ agree: false, email: "a@b" });
+ });
+
+ it("records fields cleared from baseline", () => {
+ const baseline = new Map([["name", "Alice"]]);
+ const fields = new Map(); // cleared
+ const diff = computeDiff([], [], fields, baseline);
+ expect(diff.formFields).toEqual({ name: "" });
+ });
+
+ it("produces empty diff when all form values match baseline", () => {
+ const baseline = new Map([
+ ["name", "Alice"],
+ ["agree", true],
+ ]);
+ const diff = computeDiff([], [], new Map(baseline), baseline);
+ expect(diff.formFields).toEqual({});
+ expect(isDiffEmpty(diff)).toBe(true);
+ });
+
+ it("round-trips through mergeAnnotations", () => {
+ const userStamp: PdfAnnotationDef = {
+ type: "stamp",
+ id: "s1",
+ page: 1,
+ x: 300,
+ y: 400,
+ label: "FINAL",
+ };
+ const current = [pdfHighlight, userStamp]; // removed pdfNote, added stamp
+ const diff = computeDiff([pdfNote, pdfHighlight], current, new Map());
+
+ // Merging back should produce the current set
+ const merged = mergeAnnotations([pdfNote, pdfHighlight], diff);
+ expect(merged.map((a) => a.id).sort()).toEqual(
+ current.map((a) => a.id).sort(),
+ );
+ });
+
+ // Backs the post-computeDiff union step in mcp-app.ts persistAnnotations /
+ // getAnnotatedPdfBytes: with the lazy per-page baseline scan, computeDiff
+ // alone cannot produce `removed` for natives on pages not yet visited, so
+ // callers must union restoredRemovedIds. These tests pin the contract those
+ // call sites depend on.
+ describe("partial baseline (lazy-scan tombstone preservation)", () => {
+ const tombstoned = "pdf-5-0";
+ const userNote: PdfAnnotationDef = {
+ type: "note",
+ id: "u1",
+ page: 1,
+ x: 10,
+ y: 10,
+ content: "unrelated edit",
+ };
+
+ /** Mirrors the union loop in mcp-app.ts persistAnnotations. */
+ function unionRestored(
+ diff: AnnotationDiff,
+ restored: Iterable,
+ currentIds: Set,
+ ): void {
+ for (const id of restored) {
+ if (!currentIds.has(id) && !diff.removed.includes(id)) {
+ diff.removed.push(id);
+ }
+ }
+ }
+
+ it("computeDiff alone drops tombstones for unscanned pages; the union step preserves them", () => {
+ const baseline: PdfAnnotationDef[] = []; // page with the native not yet scanned
+ const current = [userNote];
+ const diff = computeDiff(baseline, current, new Map());
+ expect(diff.removed).toEqual([]); // proves the union step is load-bearing
+
+ unionRestored(diff, [tombstoned], new Set(current.map((a) => a.id)));
+
+ expect(diff.removed).toEqual([tombstoned]);
+ expect(isDiffEmpty(diff)).toBe(false);
+ expect(deserializeDiff(serializeDiff(diff)).removed).toEqual([
+ tombstoned,
+ ]);
+ });
+
+ it("a tombstone the user re-added is excluded from the union", () => {
+ const reAdded: PdfAnnotationDef = {
+ type: "note",
+ id: tombstoned,
+ page: 3,
+ x: 0,
+ y: 0,
+ content: "back",
+ };
+ const current = [userNote, reAdded];
+ const diff = computeDiff([], current, new Map());
+ unionRestored(diff, [tombstoned], new Set(current.map((a) => a.id)));
+ expect(diff.removed).toEqual([]);
+ });
+
+ it("union does not duplicate ids once the page is scanned and computeDiff produces them", () => {
+ const native: PdfAnnotationDef = {
+ type: "note",
+ id: tombstoned,
+ page: 3,
+ x: 0,
+ y: 0,
+ content: "native",
+ };
+ const diff = computeDiff([native], [userNote], new Map());
+ expect(diff.removed).toEqual([tombstoned]);
+ unionRestored(diff, [tombstoned], new Set([userNote.id]));
+ expect(diff.removed).toEqual([tombstoned]);
+ });
+
+ it("removedRefs from restored tombstones parse for buildAnnotatedPdfBytes; non-ref ids are skipped", () => {
+ const restored = new Set(["pdf-5-0", "pdf-12R", "pdf-2-idx-7"]);
+ const removedRefs = [...restored]
+ .map(parseAnnotationRef)
+ .filter((r): r is NonNullable => r !== null);
+ expect(removedRefs).toEqual([
+ { objectNumber: 5, generationNumber: 0 },
+ { objectNumber: 12, generationNumber: 0 },
+ ]);
+ });
+ });
+});
+
+// =============================================================================
+// Color Conversion
+// =============================================================================
+
+describe("cssColorToRgb", () => {
+ it("parses 6-digit hex", () => {
+ expect(cssColorToRgb("#ff0000")).toEqual({ r: 1, g: 0, b: 0 });
+ expect(cssColorToRgb("#00ff00")).toEqual({ r: 0, g: 1, b: 0 });
+ expect(cssColorToRgb("#0000ff")).toEqual({ r: 0, g: 0, b: 1 });
+ });
+
+ it("parses 3-digit hex", () => {
+ expect(cssColorToRgb("#f00")).toEqual({ r: 1, g: 0, b: 0 });
+ expect(cssColorToRgb("#0f0")).toEqual({ r: 0, g: 1, b: 0 });
+ });
+
+ it("parses 8-digit hex (ignores alpha)", () => {
+ const result = cssColorToRgb("#ff000080");
+ expect(result).toEqual({ r: 1, g: 0, b: 0 });
+ });
+
+ it("parses rgb()", () => {
+ expect(cssColorToRgb("rgb(255, 0, 0)")).toEqual({ r: 1, g: 0, b: 0 });
+ expect(cssColorToRgb("rgb(128, 128, 128)")).toEqual({
+ r: 128 / 255,
+ g: 128 / 255,
+ b: 128 / 255,
+ });
+ });
+
+ it("parses rgba()", () => {
+ expect(cssColorToRgb("rgba(255, 128, 0, 0.5)")).toEqual({
+ r: 1,
+ g: 128 / 255,
+ b: 0,
+ });
+ });
+
+ it("returns null for invalid input", () => {
+ expect(cssColorToRgb("not-a-color")).toBeNull();
+ expect(cssColorToRgb("")).toBeNull();
+ expect(cssColorToRgb("red")).toBeNull();
+ });
+
+ it("is case-insensitive for hex", () => {
+ expect(cssColorToRgb("#FF0000")).toEqual({ r: 1, g: 0, b: 0 });
+ expect(cssColorToRgb("#aaBBcc")).toEqual({
+ r: 0xaa / 255,
+ g: 0xbb / 255,
+ b: 0xcc / 255,
+ });
+ });
+});
+
+describe("defaultColor", () => {
+ it("returns yellow for highlights", () => {
+ expect(defaultColor("highlight")).toBe("#ffff00");
+ });
+
+ it("returns red for underline and strikethrough", () => {
+ expect(defaultColor("underline")).toBe("#ff0000");
+ expect(defaultColor("strikethrough")).toBe("#ff0000");
+ });
+
+ it("returns distinct colors for each type", () => {
+ const types: PdfAnnotationDef["type"][] = [
+ "highlight",
+ "underline",
+ "strikethrough",
+ "note",
+ "rectangle",
+ "freetext",
+ "stamp",
+ "image",
+ ];
+ const colors = types.map(defaultColor);
+ // At minimum underline and strikethrough share the same color
+ const unique = new Set(colors);
+ expect(unique.size).toBeGreaterThanOrEqual(5);
+ });
+});
+
+// =============================================================================
+// PDF.js Annotation Import
+// =============================================================================
+
+describe("importPdfjsAnnotation", () => {
+ it("imports a highlight annotation", () => {
+ const ann = {
+ annotationType: 9,
+ ref: { num: 5, gen: 0 },
+ rect: [72, 700, 272, 712],
+ // pdf.js emits a FLAT Float32Array, not nested arrays.
+ quadPoints: new Float32Array([72, 712, 272, 712, 72, 700, 272, 700]),
+ color: new Uint8ClampedArray([255, 255, 0]),
+ contentsObj: { str: "Important" },
+ };
+ const result = importPdfjsAnnotation(ann, 1, 0);
+ expect(result).not.toBeNull();
+ expect(result!.type).toBe("highlight");
+ expect(result!.id).toBe("pdf-5-0");
+ expect(result!.page).toBe(1);
+ expect((result as any).rects).toHaveLength(1);
+ expect((result as any).content).toBe("Important");
+ expect((result as any).color).toBe("#ffff00");
+ });
+
+ it("imports a multi-line highlight (multiple quads โ multiple rects)", () => {
+ // Regression: the parser iterated quadPoints as if nested; pdf.js's
+ // flat array yielded numbers, so rects stayed empty and import bailed.
+ const ann = {
+ annotationType: 9,
+ ref: { num: 8, gen: 0 },
+ quadPoints: new Float32Array([
+ // line 1
+ 72, 712, 272, 712, 72, 700, 272, 700,
+ // line 2
+ 72, 698, 200, 698, 72, 686, 200, 686,
+ ]),
+ color: new Uint8ClampedArray([255, 255, 0]),
+ };
+ const result = importPdfjsAnnotation(ann, 1, 0);
+ expect(result).not.toBeNull();
+ expect(result!.type).toBe("highlight");
+ expect((result as any).rects).toHaveLength(2);
+ expect((result as any).rects[0]).toEqual({
+ x: 72,
+ y: 700,
+ width: 200,
+ height: 12,
+ });
+ expect((result as any).rects[1]).toEqual({
+ x: 72,
+ y: 686,
+ width: 128,
+ height: 12,
+ });
+ });
+
+ it("falls back to ann.rect when quadPoints is absent", () => {
+ const ann = {
+ annotationType: 9,
+ ref: { num: 9, gen: 0 },
+ rect: [72, 700, 272, 712],
+ color: new Uint8ClampedArray([255, 255, 0]),
+ };
+ const result = importPdfjsAnnotation(ann, 1, 0);
+ expect(result).not.toBeNull();
+ expect((result as any).rects).toHaveLength(1);
+ });
+
+ it("imports an underline annotation", () => {
+ const ann = {
+ annotationType: 10,
+ ref: { num: 6, gen: 0 },
+ rect: [72, 700, 272, 712],
+ // pdf.js emits a FLAT Float32Array, not nested arrays.
+ quadPoints: new Float32Array([72, 712, 272, 712, 72, 700, 272, 700]),
+ color: new Uint8ClampedArray([255, 0, 0]),
+ };
+ const result = importPdfjsAnnotation(ann, 1, 0);
+ expect(result).not.toBeNull();
+ expect(result!.type).toBe("underline");
+ expect(result!.id).toBe("pdf-6-0");
+ });
+
+ it("imports a strikethrough annotation", () => {
+ const ann = {
+ annotationType: 12,
+ ref: { num: 7, gen: 0 },
+ rect: [72, 700, 272, 712],
+ // pdf.js emits a FLAT Float32Array, not nested arrays.
+ quadPoints: new Float32Array([72, 712, 272, 712, 72, 700, 272, 700]),
+ color: new Uint8ClampedArray([255, 0, 0]),
+ };
+ const result = importPdfjsAnnotation(ann, 2, 0);
+ expect(result).not.toBeNull();
+ expect(result!.type).toBe("strikethrough");
+ expect(result!.page).toBe(2);
+ });
+
+ it("imports an unsupported subtype as 'imported' (placement only)", () => {
+ // annotationType 15 = Ink, not in PDFJS_TYPE_MAP. We keep it as a
+ // placement-only "imported" record so it's listed in the panel and
+ // rendered from annotationCanvasMap instead of being dropped.
+ const ann = {
+ annotationType: 15,
+ subtype: "Ink",
+ id: "200R",
+ rect: [100, 200, 180, 260],
+ };
+ const result = importPdfjsAnnotation(ann, 3, 0);
+ expect(result).not.toBeNull();
+ expect(result!.type).toBe("imported");
+ expect(result!.page).toBe(3);
+ expect((result as any).pdfjsId).toBe("200R");
+ expect((result as any).subtype).toBe("Ink");
+ expect((result as any).width).toBeCloseTo(80);
+ expect((result as any).height).toBeCloseTo(60);
+ });
+
+ it("imports an appearance-stream stamp as 'imported' (not text-label)", () => {
+ // A Stamp with hasAppearance carries a custom visual (e.g. an image
+ // signature) that our text-label StampAnnotation can't reproduce.
+ const ann = {
+ annotationType: 13,
+ subtype: "Stamp",
+ id: "118R",
+ rect: [420, 760, 514, 792],
+ hasAppearance: true,
+ contentsObj: { str: "DRAFT" },
+ };
+ const result = importPdfjsAnnotation(ann, 1, 0);
+ expect(result!.type).toBe("imported");
+ expect((result as any).pdfjsId).toBe("118R");
+ expect((result as any).subtype).toBe("Stamp");
+ });
+
+ it("computeDiff: 'imported' present in both baseline and current โ no diff", () => {
+ const imp: PdfAnnotationDef = {
+ type: "imported",
+ id: "pdf-118R",
+ page: 1,
+ x: 420,
+ y: 760,
+ width: 94,
+ height: 32,
+ pdfjsId: "118R",
+ subtype: "Stamp",
+ };
+ const diff = computeDiff([imp], [imp], new Map());
+ expect(diff.added).toHaveLength(0);
+ expect(diff.removed).toHaveLength(0);
+ });
+
+ it("computeDiff: deleting an 'imported' annotation lists it in removed", () => {
+ const imp: PdfAnnotationDef = {
+ type: "imported",
+ id: "pdf-118R",
+ page: 1,
+ x: 420,
+ y: 760,
+ width: 94,
+ height: 32,
+ pdfjsId: "118R",
+ subtype: "Stamp",
+ };
+ const diff = computeDiff([imp], [], new Map());
+ expect(diff.removed).toEqual(["pdf-118R"]);
+ });
+
+ it("imports a note (Text) annotation", () => {
+ const ann = {
+ annotationType: 1,
+ ref: { num: 10, gen: 0 },
+ rect: [100, 400, 124, 424],
+ contentsObj: { str: "Remember this" },
+ color: new Uint8ClampedArray([245, 166, 35]),
+ };
+ const result = importPdfjsAnnotation(ann, 1, 0);
+ expect(result).not.toBeNull();
+ expect(result!.type).toBe("note");
+ expect(result!.id).toBe("pdf-10-0");
+ const note = result as any;
+ expect(note.x).toBe(100);
+ expect(note.y).toBe(424); // y + height
+ expect(note.content).toBe("Remember this");
+ });
+
+ it("imports a rectangle (Square) annotation", () => {
+ const ann = {
+ annotationType: 5,
+ ref: { num: 12, gen: 0 },
+ rect: [50, 300, 250, 400],
+ color: new Uint8ClampedArray([0, 102, 204]),
+ };
+ const result = importPdfjsAnnotation(ann, 1, 0);
+ expect(result).not.toBeNull();
+ expect(result!.type).toBe("rectangle");
+ const rect = result as any;
+ expect(rect.x).toBe(50);
+ expect(rect.y).toBe(300);
+ expect(rect.width).toBe(200);
+ expect(rect.height).toBe(100);
+ });
+
+ it("imports a freetext annotation", () => {
+ const ann = {
+ annotationType: 3,
+ ref: { num: 15, gen: 0 },
+ rect: [100, 500, 300, 520],
+ contentsObj: { str: "Hello World" },
+ color: new Uint8ClampedArray([51, 51, 51]),
+ fontSize: 14,
+ };
+ const result = importPdfjsAnnotation(ann, 1, 0);
+ expect(result).not.toBeNull();
+ expect(result!.type).toBe("freetext");
+ const ft = result as any;
+ expect(ft.content).toBe("Hello World");
+ expect(ft.fontSize).toBe(14);
+ });
+
+ it("imports a stamp annotation", () => {
+ const ann = {
+ annotationType: 13,
+ ref: { num: 20, gen: 0 },
+ rect: [200, 600, 350, 640],
+ name: "APPROVED",
+ color: new Uint8ClampedArray([204, 0, 0]),
+ };
+ const result = importPdfjsAnnotation(ann, 1, 0);
+ expect(result).not.toBeNull();
+ expect(result!.type).toBe("stamp");
+ expect((result as any).label).toBe("APPROVED");
+ });
+
+ it("returns null for unsupported types (LINK=2)", () => {
+ const ann = {
+ annotationType: 2,
+ ref: { num: 30, gen: 0 },
+ rect: [0, 0, 100, 20],
+ };
+ expect(importPdfjsAnnotation(ann, 1, 0)).toBeNull();
+ });
+
+ it("returns null for form widgets (type 20)", () => {
+ const ann = {
+ annotationType: 20,
+ ref: { num: 31, gen: 0 },
+ rect: [0, 0, 100, 20],
+ };
+ expect(importPdfjsAnnotation(ann, 1, 0)).toBeNull();
+ });
+
+ it("generates fallback ID when ref is missing", () => {
+ const ann = {
+ annotationType: 1,
+ rect: [100, 400, 124, 424],
+ contentsObj: { str: "No ref" },
+ };
+ const result = importPdfjsAnnotation(ann, 3, 7);
+ expect(result).not.toBeNull();
+ expect(result!.id).toBe("pdf-3-7");
+ });
+
+ it("uses ann.id when ref is missing but id exists", () => {
+ const ann = {
+ annotationType: 1,
+ id: "custom-id",
+ rect: [100, 400, 124, 424],
+ contentsObj: { str: "With id" },
+ };
+ const result = importPdfjsAnnotation(ann, 1, 0);
+ expect(result).not.toBeNull();
+ expect(result!.id).toBe("pdf-custom-id");
+ });
+
+ it("handles null color gracefully", () => {
+ const ann = {
+ annotationType: 5,
+ ref: { num: 40, gen: 0 },
+ rect: [50, 300, 250, 400],
+ color: null,
+ };
+ const result = importPdfjsAnnotation(ann, 1, 0);
+ expect(result).not.toBeNull();
+ expect((result as any).color).toBeUndefined();
+ });
+
+ it("handles highlight without quadPoints (uses rect)", () => {
+ const ann = {
+ annotationType: 9,
+ ref: { num: 50, gen: 0 },
+ rect: [72, 700, 272, 712],
+ color: new Uint8ClampedArray([255, 255, 0]),
+ };
+ const result = importPdfjsAnnotation(ann, 1, 0);
+ expect(result).not.toBeNull();
+ expect(result!.type).toBe("highlight");
+ expect((result as any).rects).toHaveLength(1);
+ expect((result as any).rects[0]).toEqual({
+ x: 72,
+ y: 700,
+ width: 200,
+ height: 12,
+ });
+ });
+});
+
+// =============================================================================
+// Base64 Helpers
+// =============================================================================
+
+describe("base64 helpers", () => {
+ it("round-trips uint8array through base64", () => {
+ const original = new Uint8Array([0, 1, 2, 255, 128, 64]);
+ const base64 = uint8ArrayToBase64(original);
+ const restored = base64ToUint8Array(base64);
+ expect(restored).toEqual(original);
+ });
+
+ it("handles empty array", () => {
+ const empty = new Uint8Array(0);
+ const base64 = uint8ArrayToBase64(empty);
+ expect(base64).toBe("");
+ expect(base64ToUint8Array(base64)).toEqual(empty);
+ });
+
+ it("produces valid base64", () => {
+ const data = new Uint8Array([72, 101, 108, 108, 111]); // "Hello"
+ const base64 = uint8ArrayToBase64(data);
+ expect(base64).toBe("SGVsbG8=");
+ });
+});
+
+// =============================================================================
+// PDF Annotation Dict Creation (integration test with pdf-lib)
+// =============================================================================
+
+describe("parseAnnotationRef", () => {
+ it("parses pdf-- ids", () => {
+ expect(parseAnnotationRef("pdf-118-0")).toEqual({
+ objectNumber: 118,
+ generationNumber: 0,
+ });
+ expect(parseAnnotationRef("pdf-5-2")).toEqual({
+ objectNumber: 5,
+ generationNumber: 2,
+ });
+ });
+ it("parses pdf-R ids (pdf.js string id, gen=0)", () => {
+ expect(parseAnnotationRef("pdf-118R")).toEqual({
+ objectNumber: 118,
+ generationNumber: 0,
+ });
+ });
+ it("returns null for page-index fallback ids", () => {
+ expect(parseAnnotationRef("pdf-1-idx-3")).toBeNull();
+ expect(parseAnnotationRef("user-abc")).toBeNull();
+ });
+});
+
+describe("buildAnnotatedPdfBytes", () => {
+ let blankPdfBytes: Uint8Array;
+
+ // Create a minimal blank PDF for testing
+ beforeAll(async () => {
+ const doc = await PDFDocument.create();
+ doc.addPage([612, 792]); // US Letter
+ blankPdfBytes = await doc.save();
+ });
+
+ it("returns valid PDF bytes for empty annotations", async () => {
+ const result = await buildAnnotatedPdfBytes(blankPdfBytes, [], new Map());
+ expect(result).toBeInstanceOf(Uint8Array);
+ expect(result.length).toBeGreaterThan(0);
+
+ // Verify it's a valid PDF (starts with %PDF)
+ const header = String.fromCharCode(...result.slice(0, 5));
+ expect(header).toBe("%PDF-");
+ });
+
+ it("strips removedRefs entries from each page's /Annots array", async () => {
+ // Seed: add two highlights, save, capture their object refs.
+ const seeded = await buildAnnotatedPdfBytes(
+ blankPdfBytes,
+ [
+ {
+ type: "highlight",
+ id: "h1",
+ page: 1,
+ rects: [{ x: 72, y: 700, width: 100, height: 12 }],
+ color: "#ffff00",
+ },
+ {
+ type: "highlight",
+ id: "h2",
+ page: 1,
+ rects: [{ x: 72, y: 680, width: 100, height: 12 }],
+ color: "#ffff00",
+ },
+ ],
+ new Map(),
+ );
+ const seededDoc = await PDFDocument.load(seeded);
+ const annots = seededDoc.getPage(0).node.Annots()!;
+ expect(annots.size()).toBe(2);
+ const ref0 = annots.get(0) as unknown as {
+ objectNumber: number;
+ generationNumber: number;
+ };
+
+ // Now remove the first one by ref.
+ const stripped = await buildAnnotatedPdfBytes(seeded, [], new Map(), [
+ {
+ objectNumber: ref0.objectNumber,
+ generationNumber: ref0.generationNumber,
+ },
+ ]);
+ const strippedDoc = await PDFDocument.load(stripped);
+ const remaining = strippedDoc.getPage(0).node.Annots();
+ expect(remaining?.size() ?? 0).toBe(1);
+ });
+
+ it("removedRefs ignores refs not present in /Annots", async () => {
+ const out = await buildAnnotatedPdfBytes(blankPdfBytes, [], new Map(), [
+ { objectNumber: 9999, generationNumber: 0 },
+ ]);
+ const doc = await PDFDocument.load(out);
+ expect(doc.getPage(0).node.Annots()?.size() ?? 0).toBe(0);
+ });
+
+ it("adds highlight annotation to PDF", async () => {
+ const annotations: PdfAnnotationDef[] = [
+ {
+ type: "highlight",
+ id: "h1",
+ page: 1,
+ rects: [{ x: 72, y: 700, width: 200, height: 12 }],
+ color: "#ffff00",
+ content: "Important text",
+ },
+ ];
+
+ const result = await buildAnnotatedPdfBytes(
+ blankPdfBytes,
+ annotations,
+ new Map(),
+ );
+
+ // Load result and check annotations exist
+ const doc = await PDFDocument.load(result);
+ const page = doc.getPages()[0];
+ const annots = page.node.Annots();
+ expect(annots).toBeDefined();
+ expect(annots!.size()).toBeGreaterThanOrEqual(1);
+ });
+
+ it("adds note annotation to PDF", async () => {
+ const annotations: PdfAnnotationDef[] = [
+ {
+ type: "note",
+ id: "n1",
+ page: 1,
+ x: 100,
+ y: 500,
+ content: "This is a note",
+ color: "#f5a623",
+ },
+ ];
+
+ const result = await buildAnnotatedPdfBytes(
+ blankPdfBytes,
+ annotations,
+ new Map(),
+ );
+ const doc = await PDFDocument.load(result);
+ const page = doc.getPages()[0];
+ const annots = page.node.Annots();
+ expect(annots).toBeDefined();
+ expect(annots!.size()).toBeGreaterThanOrEqual(1);
+ });
+
+ it("adds rectangle annotation to PDF", async () => {
+ const annotations: PdfAnnotationDef[] = [
+ {
+ type: "rectangle",
+ id: "r1",
+ page: 1,
+ x: 50,
+ y: 300,
+ width: 200,
+ height: 100,
+ color: "#0066cc",
+ fillColor: "#e0e8ff",
+ },
+ ];
+
+ const result = await buildAnnotatedPdfBytes(
+ blankPdfBytes,
+ annotations,
+ new Map(),
+ );
+ const doc = await PDFDocument.load(result);
+ const page = doc.getPages()[0];
+ const annots = page.node.Annots();
+ expect(annots).toBeDefined();
+ });
+
+ it("saves rectangle stroke and fill colors in PDF dict", async () => {
+ const annotations: PdfAnnotationDef[] = [
+ {
+ type: "rectangle",
+ id: "r1",
+ page: 1,
+ x: 50,
+ y: 300,
+ width: 200,
+ height: 100,
+ color: "#ff0000",
+ fillColor: "#00ff00",
+ },
+ ];
+
+ const result = await buildAnnotatedPdfBytes(
+ blankPdfBytes,
+ annotations,
+ new Map(),
+ );
+ const doc = await PDFDocument.load(result);
+ const page = doc.getPages()[0];
+ const annots = page.node.Annots()!;
+ const annotRef = annots.get(annots.size() - 1);
+ const annotDict = doc.context.lookup(annotRef) as PDFDict;
+
+ // Check /C (stroke color) = [1, 0, 0] for #ff0000
+ const cArr = annotDict.get(PDFName.of("C")) as PDFArray;
+ expect(cArr).toBeDefined();
+ expect((cArr.get(0) as PDFNumber).asNumber()).toBe(1); // r
+ expect((cArr.get(1) as PDFNumber).asNumber()).toBe(0); // g
+ expect((cArr.get(2) as PDFNumber).asNumber()).toBe(0); // b
+
+ // Check /IC (fill color) = [0, 1, 0] for #00ff00
+ const icArr = annotDict.get(PDFName.of("IC")) as PDFArray;
+ expect(icArr).toBeDefined();
+ expect((icArr.get(0) as PDFNumber).asNumber()).toBe(0); // r
+ expect((icArr.get(1) as PDFNumber).asNumber()).toBe(1); // g
+ expect((icArr.get(2) as PDFNumber).asNumber()).toBe(0); // b
+ });
+
+ it("adds freetext annotation to PDF", async () => {
+ const annotations: PdfAnnotationDef[] = [
+ {
+ type: "freetext",
+ id: "ft1",
+ page: 1,
+ x: 72,
+ y: 600,
+ content: "Hello World",
+ fontSize: 16,
+ color: "#333333",
+ },
+ ];
+
+ const result = await buildAnnotatedPdfBytes(
+ blankPdfBytes,
+ annotations,
+ new Map(),
+ );
+ const doc = await PDFDocument.load(result);
+ const annots = doc.getPages()[0].node.Annots();
+ expect(annots).toBeDefined();
+ });
+
+ it("adds stamp annotation with appearance stream", async () => {
+ const annotations: PdfAnnotationDef[] = [
+ {
+ type: "stamp",
+ id: "s1",
+ page: 1,
+ x: 200,
+ y: 400,
+ label: "DRAFT",
+ color: "#cc0000",
+ },
+ ];
+
+ const result = await buildAnnotatedPdfBytes(
+ blankPdfBytes,
+ annotations,
+ new Map(),
+ );
+ const doc = await PDFDocument.load(result);
+ const annots = doc.getPages()[0].node.Annots();
+ expect(annots).toBeDefined();
+ });
+
+ it("saves stamp color in both /C and appearance stream", async () => {
+ const annotations: PdfAnnotationDef[] = [
+ {
+ type: "stamp",
+ id: "s1",
+ page: 1,
+ x: 200,
+ y: 400,
+ label: "OK",
+ color: "#00ff00",
+ },
+ ];
+
+ const result = await buildAnnotatedPdfBytes(
+ blankPdfBytes,
+ annotations,
+ new Map(),
+ );
+ const doc = await PDFDocument.load(result);
+ const annots = doc.getPages()[0].node.Annots()!;
+ const annotRef = annots.get(annots.size() - 1);
+ const annotDict = doc.context.lookup(annotRef) as PDFDict;
+
+ // Check /C color
+ const cArr = annotDict.get(PDFName.of("C")) as PDFArray;
+ expect(cArr).toBeDefined();
+ expect((cArr.get(0) as PDFNumber).asNumber()).toBe(0);
+ expect((cArr.get(1) as PDFNumber).asNumber()).toBe(1);
+ expect((cArr.get(2) as PDFNumber).asNumber()).toBe(0);
+
+ // Check appearance stream exists and contains the color
+ const ap = annotDict.get(PDFName.of("AP")) as PDFDict;
+ expect(ap).toBeDefined();
+ const nRef = ap.get(PDFName.of("N"));
+ expect(nRef).toBeDefined();
+ });
+
+ it("saves stamp rotation via appearance stream matrix", async () => {
+ const annotations: PdfAnnotationDef[] = [
+ {
+ type: "stamp",
+ id: "s1",
+ page: 1,
+ x: 200,
+ y: 400,
+ label: "TILTED",
+ color: "#cc0000",
+ rotation: 45,
+ },
+ ];
+
+ const result = await buildAnnotatedPdfBytes(
+ blankPdfBytes,
+ annotations,
+ new Map(),
+ );
+ const doc = await PDFDocument.load(result);
+ const annots = doc.getPages()[0].node.Annots()!;
+ const annotRef = annots.get(annots.size() - 1);
+ const annotDict = doc.context.lookup(annotRef) as PDFDict;
+
+ // Verify the appearance stream has a /Matrix with rotation
+ const ap = annotDict.get(PDFName.of("AP")) as PDFDict;
+ expect(ap).toBeDefined();
+ const nRef = ap.get(PDFName.of("N"));
+ const apStream = doc.context.lookup(nRef) as any;
+ expect(apStream).toBeDefined();
+ // The stream's dict should have a Matrix entry for rotation
+ const streamDict = apStream.dict || apStream;
+ const matrix = streamDict.get(PDFName.of("Matrix"));
+ expect(matrix).toBeDefined();
+ });
+
+ it("adds multiple annotations of different types", async () => {
+ const annotations: PdfAnnotationDef[] = [
+ {
+ type: "highlight",
+ id: "h1",
+ page: 1,
+ rects: [{ x: 72, y: 700, width: 200, height: 12 }],
+ },
+ {
+ type: "note",
+ id: "n1",
+ page: 1,
+ x: 100,
+ y: 500,
+ content: "A note",
+ },
+ {
+ type: "rectangle",
+ id: "r1",
+ page: 1,
+ x: 50,
+ y: 300,
+ width: 100,
+ height: 50,
+ },
+ {
+ type: "stamp",
+ id: "s1",
+ page: 1,
+ x: 200,
+ y: 400,
+ label: "FINAL",
+ },
+ ];
+
+ const result = await buildAnnotatedPdfBytes(
+ blankPdfBytes,
+ annotations,
+ new Map(),
+ );
+ const doc = await PDFDocument.load(result);
+ const annots = doc.getPages()[0].node.Annots();
+ expect(annots).toBeDefined();
+ expect(annots!.size()).toBeGreaterThanOrEqual(4);
+ });
+
+ it("skips annotations with invalid page numbers", async () => {
+ const annotations: PdfAnnotationDef[] = [
+ {
+ type: "note",
+ id: "n1",
+ page: 99, // page doesn't exist
+ x: 100,
+ y: 500,
+ content: "Ghost note",
+ },
+ ];
+
+ const result = await buildAnnotatedPdfBytes(
+ blankPdfBytes,
+ annotations,
+ new Map(),
+ );
+ const doc = await PDFDocument.load(result);
+ const annots = doc.getPages()[0].node.Annots();
+ // Should have no annotations since page 99 doesn't exist
+ expect(annots?.size() ?? 0).toBe(0);
+ });
+
+ it("produces a valid re-loadable PDF", async () => {
+ const annotations: PdfAnnotationDef[] = [
+ {
+ type: "highlight",
+ id: "h1",
+ page: 1,
+ rects: [{ x: 72, y: 700, width: 200, height: 12 }],
+ color: "#ffff00",
+ },
+ {
+ type: "stamp",
+ id: "s1",
+ page: 1,
+ x: 300,
+ y: 400,
+ label: "APPROVED",
+ },
+ ];
+
+ const bytes = await buildAnnotatedPdfBytes(
+ blankPdfBytes,
+ annotations,
+ new Map(),
+ );
+
+ // Should be able to load the result again
+ const doc = await PDFDocument.load(bytes);
+ expect(doc.getPageCount()).toBe(1);
+
+ // Should be able to save it again
+ const bytes2 = await doc.save();
+ expect(bytes2.length).toBeGreaterThan(0);
+ });
+
+ describe("form field persistence", () => {
+ // One fixture with every field type we support. pdf-lib's addOptionToPage
+ // writes radio buttonValues as numeric index strings ("0","1","2"), which
+ // is the stress case โ the viewer stores those, but .select() wants labels.
+ let formPdfBytes: Uint8Array;
+ beforeAll(async () => {
+ const doc = await PDFDocument.create();
+ const page = doc.addPage([612, 792]);
+ const form = doc.getForm();
+ form.createTextField("name").addToPage(page, { x: 10, y: 700 });
+ form.createCheckBox("agree").addToPage(page, { x: 10, y: 660 });
+ const dd = form.createDropdown("country");
+ dd.addOptions(["USA", "UK", "Canada"]);
+ dd.addToPage(page, { x: 10, y: 620 });
+ const rg = form.createRadioGroup("size");
+ rg.addOptionToPage("small", page, { x: 10, y: 580 });
+ rg.addOptionToPage("medium", page, { x: 50, y: 580 });
+ rg.addOptionToPage("large", page, { x: 90, y: 580 });
+ formPdfBytes = await doc.save();
+ });
+
+ it("writes text, checkbox, dropdown, and radio (by label) in one pass", async () => {
+ const out = await buildAnnotatedPdfBytes(
+ formPdfBytes,
+ [],
+ new Map([
+ ["name", "Alice"],
+ ["agree", true],
+ ["country", "Canada"],
+ ["size", "medium"],
+ ]),
+ );
+ const form = (await PDFDocument.load(out)).getForm();
+ expect(form.getTextField("name").getText()).toBe("Alice");
+ expect(form.getCheckBox("agree").isChecked()).toBe(true);
+ expect(form.getDropdown("country").getSelected()).toEqual(["Canada"]);
+ expect(form.getRadioGroup("size").getSelected()).toBe("medium");
+ });
+
+ it("maps numeric radio buttonValue to option label by index", async () => {
+ // The viewer stores what pdf.js reports as buttonValue ("2"), not the
+ // label. Save must translate or the radio is silently dropped.
+ const out = await buildAnnotatedPdfBytes(
+ formPdfBytes,
+ [],
+ new Map([["size", "2"]]),
+ );
+ const form = (await PDFDocument.load(out)).getForm();
+ expect(form.getRadioGroup("size").getSelected()).toBe("large");
+ });
+
+ it("leaves radio unset when value is neither label nor valid index", async () => {
+ const out = await buildAnnotatedPdfBytes(
+ formPdfBytes,
+ [],
+ new Map([["size", "bogus"]]),
+ );
+ const form = (await PDFDocument.load(out)).getForm();
+ expect(form.getRadioGroup("size").getSelected()).toBeUndefined();
+ });
+
+ it("skips unknown field names without throwing", async () => {
+ const out = await buildAnnotatedPdfBytes(
+ formPdfBytes,
+ [],
+ new Map([
+ ["nonexistent", "x"],
+ ["name", "kept"],
+ ]),
+ );
+ const form = (await PDFDocument.load(out)).getForm();
+ expect(form.getTextField("name").getText()).toBe("kept");
+ });
+
+ it("a field that throws on write does not abort subsequent fields", async () => {
+ // Regression for #577: the per-field try/catch was dropped, so the
+ // first throwing field bubbled to the outer catch and silently dropped
+ // every field after it. setText() throws when value exceeds maxLength.
+ const doc = await PDFDocument.create();
+ const page = doc.addPage([612, 792]);
+ const form = doc.getForm();
+ const limited = form.createTextField("limited");
+ limited.setMaxLength(2);
+ limited.addToPage(page, { x: 10, y: 700 });
+ form.createTextField("after").addToPage(page, { x: 10, y: 660 });
+ const fixture = await doc.save();
+
+ const out = await buildAnnotatedPdfBytes(
+ fixture,
+ [],
+ new Map([
+ ["limited", "way too long"], // throws
+ ["after", "kept"],
+ ]),
+ );
+
+ const saved = (await PDFDocument.load(out)).getForm();
+ expect(saved.getTextField("after").getText()).toBe("kept");
+ // The throwing field is left at whatever pdf-lib could do with it โ
+ // we only assert it didn't poison "after".
+ });
+
+ it("radio misclassified as PDFCheckBox: string value selects the matching widget", async () => {
+ // Some PDFs (e.g. IRS/third-party forms) omit the /Ff Radio bit, so
+ // pdf-lib hands us a PDFCheckBox. The viewer stored pdf.js's
+ // buttonValue ("0"/"1"), not a boolean โ check() would always pick
+ // the first widget. setButtonGroupValue writes /V + per-widget /AS
+ // directly so the chosen widget sticks.
+ const doc = await PDFDocument.create();
+ const page = doc.addPage([612, 792]);
+ const form = doc.getForm();
+ const rg = form.createRadioGroup("Gender");
+ rg.addOptionToPage("Male", page, { x: 10, y: 700 });
+ rg.addOptionToPage("Female", page, { x: 60, y: 700 });
+ // pdf-lib's addOptionToPage writes widget on-values "0","1". Clear the
+ // Radio flag (bit 16) so the reloaded form classifies it as checkbox.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (rg.acroField as any).clearFlag(1 << 15);
+ const fixture = await doc.save();
+
+ // Sanity: reload sees it as PDFCheckBox now.
+ const reForm = (await PDFDocument.load(fixture)).getForm();
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const reField = reForm.getFieldMaybe("Gender") as any;
+ expect(reField?.constructor?.name).toBe("PDFCheckBox");
+
+ const out = await buildAnnotatedPdfBytes(
+ fixture,
+ [],
+ new Map([["Gender", "1"]]),
+ );
+
+ const saved = await PDFDocument.load(out);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const acro = (saved.getForm().getFieldMaybe("Gender") as any).acroField;
+ const v = acro.dict.get(PDFName.of("V"));
+ expect(v).toBeInstanceOf(PDFName);
+ expect((v as PDFName).decodeText()).toBe("1");
+ // Second widget /AS is the on-state, first is /Off.
+ const widgets = acro.getWidgets();
+ expect(widgets[0].getAppearanceState()?.decodeText()).toBe("Off");
+ expect(widgets[1].getAppearanceState()?.decodeText()).toBe("1");
+ });
+ });
+});
+
+// =============================================================================
+// Coordinate Conversion (model โ internal PDF coords)
+// =============================================================================
+
+describe("coordinate conversion", () => {
+ const PAGE_HEIGHT = 792; // US Letter
+
+ it("round-trips a rectangle annotation", () => {
+ const original: PdfAnnotationDef = {
+ type: "rectangle",
+ id: "r1",
+ page: 1,
+ x: 72,
+ y: 50,
+ width: 200,
+ height: 30,
+ };
+ const converted = convertFromModelCoords(original, PAGE_HEIGHT);
+ const restored = convertToModelCoords(converted, PAGE_HEIGHT);
+ expect(restored).toEqual(original);
+ });
+
+ it("round-trips a circle annotation", () => {
+ const original: PdfAnnotationDef = {
+ type: "circle",
+ id: "c1",
+ page: 1,
+ x: 100,
+ y: 200,
+ width: 50,
+ height: 50,
+ };
+ const converted = convertFromModelCoords(original, PAGE_HEIGHT);
+ const restored = convertToModelCoords(converted, PAGE_HEIGHT);
+ expect(restored).toEqual(original);
+ });
+
+ it("round-trips a note annotation", () => {
+ const original: PdfAnnotationDef = {
+ type: "note",
+ id: "n1",
+ page: 1,
+ x: 100,
+ y: 100,
+ content: "Hello",
+ };
+ const converted = convertFromModelCoords(original, PAGE_HEIGHT);
+ const restored = convertToModelCoords(converted, PAGE_HEIGHT);
+ expect(restored).toEqual(original);
+ });
+
+ it("round-trips a freetext annotation", () => {
+ const original: PdfAnnotationDef = {
+ type: "freetext",
+ id: "ft1",
+ page: 1,
+ x: 72,
+ y: 50,
+ content: "Test",
+ fontSize: 14,
+ };
+ const converted = convertFromModelCoords(original, PAGE_HEIGHT);
+ const restored = convertToModelCoords(converted, PAGE_HEIGHT);
+ expect(restored).toEqual(original);
+ });
+
+ it("round-trips a stamp annotation", () => {
+ const original: PdfAnnotationDef = {
+ type: "stamp",
+ id: "s1",
+ page: 1,
+ x: 200,
+ y: 50,
+ label: "DRAFT",
+ };
+ const converted = convertFromModelCoords(original, PAGE_HEIGHT);
+ const restored = convertToModelCoords(converted, PAGE_HEIGHT);
+ expect(restored).toEqual(original);
+ });
+
+ it("round-trips a line annotation", () => {
+ const original: PdfAnnotationDef = {
+ type: "line",
+ id: "l1",
+ page: 1,
+ x1: 72,
+ y1: 50,
+ x2: 540,
+ y2: 742,
+ };
+ const converted = convertFromModelCoords(original, PAGE_HEIGHT);
+ const restored = convertToModelCoords(converted, PAGE_HEIGHT);
+ expect(restored).toEqual(original);
+ });
+
+ it("round-trips a highlight annotation", () => {
+ const original: PdfAnnotationDef = {
+ type: "highlight",
+ id: "h1",
+ page: 1,
+ rects: [
+ { x: 72, y: 50, width: 200, height: 12 },
+ { x: 72, y: 70, width: 150, height: 12 },
+ ],
+ };
+ const converted = convertFromModelCoords(original, PAGE_HEIGHT);
+ const restored = convertToModelCoords(converted, PAGE_HEIGHT);
+ expect(restored).toEqual(original);
+ });
+
+ it("round-trips an underline annotation", () => {
+ const original: PdfAnnotationDef = {
+ type: "underline",
+ id: "u1",
+ page: 1,
+ rects: [{ x: 72, y: 100, width: 200, height: 12 }],
+ };
+ const converted = convertFromModelCoords(original, PAGE_HEIGHT);
+ const restored = convertToModelCoords(converted, PAGE_HEIGHT);
+ expect(restored).toEqual(original);
+ });
+
+ it("round-trips a strikethrough annotation", () => {
+ const original: PdfAnnotationDef = {
+ type: "strikethrough",
+ id: "st1",
+ page: 1,
+ rects: [{ x: 72, y: 100, width: 200, height: 12 }],
+ };
+ const converted = convertFromModelCoords(original, PAGE_HEIGHT);
+ const restored = convertToModelCoords(converted, PAGE_HEIGHT);
+ expect(restored).toEqual(original);
+ });
+
+ it("converts rectangle y from model to PDF coords correctly", () => {
+ // Model: y=50 means near top of page, height=30
+ // PDF: y should be near bottom of the rect in PDF coords = 792 - 50 - 30 = 712
+ const model: PdfAnnotationDef = {
+ type: "rectangle",
+ id: "r1",
+ page: 1,
+ x: 72,
+ y: 50,
+ width: 200,
+ height: 30,
+ };
+ const pdf = convertFromModelCoords(model, PAGE_HEIGHT);
+ expect(pdf.type).toBe("rectangle");
+ expect((pdf as any).y).toBe(712); // 792 - 50 - 30
+ expect((pdf as any).x).toBe(72); // x unchanged
+ });
+
+ it("converts note y from model to PDF coords correctly", () => {
+ // Model: y=100 means 100pt from top
+ // PDF: y = 792 - 100 = 692
+ const model: PdfAnnotationDef = {
+ type: "note",
+ id: "n1",
+ page: 1,
+ x: 100,
+ y: 100,
+ content: "Test",
+ };
+ const pdf = convertFromModelCoords(model, PAGE_HEIGHT);
+ expect((pdf as any).y).toBe(692);
+ });
+
+ it("converts line endpoints from model to PDF coords correctly", () => {
+ const model: PdfAnnotationDef = {
+ type: "line",
+ id: "l1",
+ page: 1,
+ x1: 72,
+ y1: 50,
+ x2: 540,
+ y2: 742,
+ };
+ const pdf = convertFromModelCoords(model, PAGE_HEIGHT);
+ expect((pdf as any).y1).toBe(742); // 792 - 50
+ expect((pdf as any).y2).toBe(50); // 792 - 742
+ });
+
+ it("preserves non-coordinate fields during conversion", () => {
+ const original: PdfAnnotationDef = {
+ type: "rectangle",
+ id: "r1",
+ page: 2,
+ x: 72,
+ y: 50,
+ width: 200,
+ height: 30,
+ color: "#ff0000",
+ fillColor: "#00ff00",
+ rotation: 45,
+ };
+ const converted = convertFromModelCoords(original, PAGE_HEIGHT);
+ expect(converted.id).toBe("r1");
+ expect(converted.page).toBe(2);
+ expect((converted as any).color).toBe("#ff0000");
+ expect((converted as any).fillColor).toBe("#00ff00");
+ expect((converted as any).rotation).toBe(45);
+ });
+
+ it("round-trips an image annotation", () => {
+ const original: PdfAnnotationDef = {
+ type: "image",
+ id: "img1",
+ page: 1,
+ x: 72,
+ y: 50,
+ width: 200,
+ height: 150,
+ imageData: "iVBORw0KGgo=",
+ mimeType: "image/png",
+ };
+ const converted = convertFromModelCoords(original, PAGE_HEIGHT);
+ const restored = convertToModelCoords(converted, PAGE_HEIGHT);
+ expect(restored).toEqual(original);
+ });
+
+ it("converts image y from model to PDF coords correctly", () => {
+ // Same as rectangle: y = pageHeight - y - height
+ const model: PdfAnnotationDef = {
+ type: "image",
+ id: "img1",
+ page: 1,
+ x: 72,
+ y: 50,
+ width: 200,
+ height: 100,
+ };
+ const pdf = convertFromModelCoords(model, PAGE_HEIGHT);
+ expect((pdf as any).y).toBe(642); // 792 - 50 - 100
+ expect((pdf as any).x).toBe(72);
+ });
+});
+
+// =============================================================================
+// Image Annotation PDF Creation
+// =============================================================================
+
+describe("image annotation PDF creation", () => {
+ let blankPdfBytes: Uint8Array;
+
+ beforeAll(async () => {
+ const doc = await PDFDocument.create();
+ doc.addPage([612, 792]);
+ blankPdfBytes = await doc.save();
+ });
+
+ // Create a minimal valid 1x1 PNG for testing
+ function createMinimalPng(): string {
+ // 1x1 red pixel PNG (minimal valid PNG)
+ const pngBytes = new Uint8Array([
+ 0x89,
+ 0x50,
+ 0x4e,
+ 0x47,
+ 0x0d,
+ 0x0a,
+ 0x1a,
+ 0x0a, // PNG signature
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x0d,
+ 0x49,
+ 0x48,
+ 0x44,
+ 0x52, // IHDR chunk
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x01,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x01, // 1x1
+ 0x08,
+ 0x02,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x90,
+ 0x77,
+ 0x53,
+ 0xde, // 8-bit RGB
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x0c,
+ 0x49,
+ 0x44,
+ 0x41,
+ 0x54, // IDAT chunk
+ 0x08,
+ 0xd7,
+ 0x63,
+ 0xf8,
+ 0xcf,
+ 0xc0,
+ 0x00,
+ 0x00, // compressed data
+ 0x00,
+ 0x02,
+ 0x00,
+ 0x01,
+ 0xe2,
+ 0x21,
+ 0xbc,
+ 0x33, // CRC
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x49,
+ 0x45,
+ 0x4e,
+ 0x44, // IEND chunk
+ 0xae,
+ 0x42,
+ 0x60,
+ 0x82,
+ ]);
+ return uint8ArrayToBase64(pngBytes);
+ }
+
+ it("adds image annotation as Stamp with appearance stream", async () => {
+ const pngData = createMinimalPng();
+ const annotations: PdfAnnotationDef[] = [
+ {
+ type: "image",
+ id: "img1",
+ page: 1,
+ x: 72,
+ y: 300,
+ width: 200,
+ height: 150,
+ imageData: pngData,
+ mimeType: "image/png",
+ },
+ ];
+
+ const result = await buildAnnotatedPdfBytes(
+ blankPdfBytes,
+ annotations,
+ new Map(),
+ );
+ const doc = await PDFDocument.load(result);
+ const page = doc.getPages()[0];
+ const annots = page.node.Annots();
+ expect(annots).toBeDefined();
+ expect(annots!.size()).toBeGreaterThanOrEqual(1);
+
+ // Verify it's a Stamp subtype
+ const annotRef = annots!.get(annots!.size() - 1);
+ const annotDict = doc.context.lookup(annotRef) as PDFDict;
+ expect(annotDict.get(PDFName.of("Subtype"))).toEqual(PDFName.of("Stamp"));
+
+ // Verify it has an appearance stream
+ const ap = annotDict.get(PDFName.of("AP")) as PDFDict;
+ expect(ap).toBeDefined();
+ expect(ap.get(PDFName.of("N"))).toBeDefined();
+ });
+
+ it("produces a valid re-loadable PDF with image annotation", async () => {
+ const pngData = createMinimalPng();
+ const annotations: PdfAnnotationDef[] = [
+ {
+ type: "image",
+ id: "img1",
+ page: 1,
+ x: 72,
+ y: 300,
+ width: 200,
+ height: 150,
+ imageData: pngData,
+ },
+ ];
+
+ const bytes = await buildAnnotatedPdfBytes(
+ blankPdfBytes,
+ annotations,
+ new Map(),
+ );
+ // Should be able to load the result again
+ const doc = await PDFDocument.load(bytes);
+ expect(doc.getPageCount()).toBe(1);
+ // Should be able to save it again
+ const bytes2 = await doc.save();
+ expect(bytes2.length).toBeGreaterThan(0);
+ });
+
+ it("skips appearance stream when no imageData", async () => {
+ const annotations: PdfAnnotationDef[] = [
+ {
+ type: "image",
+ id: "img1",
+ page: 1,
+ x: 72,
+ y: 300,
+ width: 200,
+ height: 150,
+ imageUrl: "https://example.com/image.png",
+ },
+ ];
+
+ const result = await buildAnnotatedPdfBytes(
+ blankPdfBytes,
+ annotations,
+ new Map(),
+ );
+ const doc = await PDFDocument.load(result);
+ const page = doc.getPages()[0];
+ const annots = page.node.Annots();
+ expect(annots).toBeDefined();
+
+ // No AP dict since no imageData
+ const annotRef = annots!.get(annots!.size() - 1);
+ const annotDict = doc.context.lookup(annotRef) as PDFDict;
+ expect(annotDict.get(PDFName.of("AP"))).toBeUndefined();
+ });
+});
diff --git a/examples/pdf-server/src/pdf-annotations.ts b/examples/pdf-server/src/pdf-annotations.ts
new file mode 100644
index 0000000..201fe8d
--- /dev/null
+++ b/examples/pdf-server/src/pdf-annotations.ts
@@ -0,0 +1,1295 @@
+/**
+ * PDF Annotation Helpers
+ *
+ * Pure functions for annotation persistence (diff-based model),
+ * color conversion, and PDF annotation dict creation using pdf-lib.
+ *
+ * The diff-based model stores only changes relative to the PDF's
+ * native annotations: additions, removals, and modifications.
+ * This keeps localStorage small and preserves round-trip fidelity.
+ */
+
+import {
+ PDFDocument,
+ PDFDict,
+ PDFName,
+ PDFArray,
+ PDFNumber,
+ PDFString,
+ PDFHexString,
+ StandardFonts,
+ PDFTextField,
+ PDFCheckBox,
+ PDFDropdown,
+ PDFOptionList,
+ PDFRadioGroup,
+ type PDFForm,
+} from "@cantoo/pdf-lib";
+
+// =============================================================================
+// Types
+// =============================================================================
+
+export interface Rect {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+export interface AnnotationBase {
+ id: string;
+ page: number;
+}
+
+export interface HighlightAnnotation extends AnnotationBase {
+ type: "highlight";
+ rects: Rect[];
+ color?: string;
+ content?: string;
+}
+
+export interface UnderlineAnnotation extends AnnotationBase {
+ type: "underline";
+ rects: Rect[];
+ color?: string;
+}
+
+export interface StrikethroughAnnotation extends AnnotationBase {
+ type: "strikethrough";
+ rects: Rect[];
+ color?: string;
+}
+
+export interface NoteAnnotation extends AnnotationBase {
+ type: "note";
+ x: number;
+ y: number;
+ content: string;
+ color?: string;
+}
+
+export interface RectangleAnnotation extends AnnotationBase {
+ type: "rectangle";
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ color?: string;
+ fillColor?: string;
+ rotation?: number;
+}
+
+export interface CircleAnnotation extends AnnotationBase {
+ type: "circle";
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ color?: string;
+ fillColor?: string;
+}
+
+export interface LineAnnotation extends AnnotationBase {
+ type: "line";
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+ color?: string;
+}
+
+export interface FreetextAnnotation extends AnnotationBase {
+ type: "freetext";
+ x: number;
+ y: number;
+ content: string;
+ fontSize?: number;
+ color?: string;
+}
+
+export interface StampAnnotation extends AnnotationBase {
+ type: "stamp";
+ x: number;
+ y: number;
+ label: string;
+ color?: string;
+ rotation?: number;
+}
+
+export interface ImageAnnotation extends AnnotationBase {
+ type: "image";
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ imageData?: string;
+ imageUrl?: string;
+ mimeType?: string;
+ rotation?: number;
+ aspect?: "preserve" | "ignore";
+}
+
+/**
+ * An annotation that already exists in the loaded PDF and that we render
+ * verbatim from its appearance stream (via pdf.js's annotationCanvasMap)
+ * rather than re-modeling it with one of our shape types.
+ *
+ * Covers two cases:
+ * - subtypes we don't model (Ink, Polygon, Caret, FileAttachment, โฆ)
+ * - subtypes we *could* model but whose appearance carries information
+ * our model would drop (e.g. Stamp with an image signature)
+ *
+ * The rasterized canvas is supplied at render time by mcp-app.ts; this
+ * struct only carries placement and identity. Coords are PDF user-space
+ * (origin bottom-left), matching the other rect-shaped types.
+ */
+export interface ImportedAnnotation extends AnnotationBase {
+ type: "imported";
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ /** pdf.js getAnnotations() id (e.g. "118R") โ key into annotationCanvasMap. */
+ pdfjsId: string;
+ /** Original PDF /Subtype (e.g. "Stamp", "Ink") for the panel label. */
+ subtype: string;
+}
+
+export type PdfAnnotationDef =
+ | HighlightAnnotation
+ | UnderlineAnnotation
+ | StrikethroughAnnotation
+ | NoteAnnotation
+ | RectangleAnnotation
+ | CircleAnnotation
+ | LineAnnotation
+ | FreetextAnnotation
+ | StampAnnotation
+ | ImageAnnotation
+ | ImportedAnnotation;
+
+// =============================================================================
+// Coordinate Conversion (model โ internal PDF coords)
+// =============================================================================
+
+/**
+ * Convert annotation coordinates from model space (top-left origin, Yโ)
+ * to internal PDF space (bottom-left origin, Yโ).
+ *
+ * Call this when receiving coordinates from the model via add/update_annotations.
+ */
+export function convertFromModelCoords(
+ def: PdfAnnotationDef,
+ pageHeight: number,
+): PdfAnnotationDef {
+ switch (def.type) {
+ case "highlight":
+ case "underline":
+ case "strikethrough":
+ return {
+ ...def,
+ rects: def.rects.map((r) => ({
+ ...r,
+ y: pageHeight - r.y - r.height,
+ })),
+ };
+ case "note":
+ case "freetext":
+ case "stamp":
+ return { ...def, y: pageHeight - def.y };
+ case "rectangle":
+ case "circle":
+ case "image":
+ case "imported":
+ return { ...def, y: pageHeight - def.y - def.height };
+ case "line":
+ return {
+ ...def,
+ y1: pageHeight - def.y1,
+ y2: pageHeight - def.y2,
+ };
+ }
+}
+
+/**
+ * Convert annotation coordinates from internal PDF space (bottom-left origin, Yโ)
+ * to model space (top-left origin, Yโ).
+ *
+ * Call this when presenting coordinates to the model (e.g. in context strings).
+ */
+export function convertToModelCoords(
+ def: PdfAnnotationDef,
+ pageHeight: number,
+): PdfAnnotationDef {
+ // The conversion is its own inverse (same formula flips both ways)
+ return convertFromModelCoords(def, pageHeight);
+}
+
+// =============================================================================
+// Diff-Based Persistence Model
+// =============================================================================
+
+/**
+ * Represents changes relative to the PDF's native annotations.
+ * Only this diff is stored in localStorage, keeping it small.
+ */
+export interface AnnotationDiff {
+ /** Annotations created by the user (not in the original PDF) */
+ added: PdfAnnotationDef[];
+ /** PDF annotation ref strings that the user deleted */
+ removed: string[];
+ /** Form field values the user filled in */
+ formFields: Record;
+}
+
+/** Create an empty diff */
+export function emptyDiff(): AnnotationDiff {
+ return { added: [], removed: [], formFields: {} };
+}
+
+/** Check if a diff has any changes */
+export function isDiffEmpty(diff: AnnotationDiff): boolean {
+ return (
+ diff.added.length === 0 &&
+ diff.removed.length === 0 &&
+ Object.keys(diff.formFields).length === 0
+ );
+}
+
+/** Serialize diff to JSON string for localStorage */
+export function serializeDiff(diff: AnnotationDiff): string {
+ return JSON.stringify(diff);
+}
+
+/** Deserialize diff from JSON string. Returns empty diff on error. */
+export function deserializeDiff(json: string): AnnotationDiff {
+ try {
+ const parsed = JSON.parse(json);
+ return {
+ added: Array.isArray(parsed.added) ? parsed.added : [],
+ removed: Array.isArray(parsed.removed) ? parsed.removed : [],
+ formFields:
+ parsed.formFields && typeof parsed.formFields === "object"
+ ? parsed.formFields
+ : {},
+ };
+ } catch {
+ return emptyDiff();
+ }
+}
+
+/**
+ * Merge PDF-native annotations with user diff to produce the final annotation set.
+ *
+ * @param pdfAnnotations - Annotations imported from the PDF file
+ * @param diff - User's local changes (additions, removals)
+ * @returns Merged annotation list
+ */
+export function mergeAnnotations(
+ pdfAnnotations: PdfAnnotationDef[],
+ diff: AnnotationDiff,
+): PdfAnnotationDef[] {
+ const removedSet = new Set(diff.removed);
+
+ // Start with PDF annotations, filtering out removed ones
+ const merged = pdfAnnotations.filter((a) => !removedSet.has(a.id));
+
+ // Add user-created annotations
+ // If an added annotation has the same ID as a PDF annotation, the added one wins
+ const addedIds = new Set(diff.added.map((a) => a.id));
+ const result = merged.filter((a) => !addedIds.has(a.id));
+ result.push(...diff.added);
+
+ return result;
+}
+
+/**
+ * Compute a diff given the PDF-native annotations and the current full set.
+ *
+ * @param pdfAnnotations - Original annotations from the PDF
+ * @param currentAnnotations - Current full annotation set (after user edits)
+ * @param formFields - Current form field values
+ * @returns The diff to persist
+ */
+export function computeDiff(
+ pdfAnnotations: PdfAnnotationDef[],
+ currentAnnotations: PdfAnnotationDef[],
+ formFields: Map,
+ baselineFormFields?: Map,
+): AnnotationDiff {
+ const pdfIds = new Set(pdfAnnotations.map((a) => a.id));
+ const currentIds = new Set(currentAnnotations.map((a) => a.id));
+
+ // Added: in current but not in PDF
+ const added = currentAnnotations.filter((a) => !pdfIds.has(a.id));
+
+ // Removed: in PDF but not in current
+ const removed = pdfAnnotations
+ .filter((a) => !currentIds.has(a.id))
+ .map((a) => a.id);
+
+ // Form fields: only values that differ from what's already in the PDF.
+ // Without a baseline, every filled field is a user edit (back-compat).
+ const formFieldsObj: Record = {};
+ for (const [k, v] of formFields) {
+ if (baselineFormFields?.get(k) === v) continue;
+ formFieldsObj[k] = v;
+ }
+ // Fields present in baseline but cleared in current are also a change
+ if (baselineFormFields) {
+ for (const [k, v] of baselineFormFields) {
+ if (!formFields.has(k) && v !== "" && v !== false) {
+ formFieldsObj[k] = formFields.get(k) ?? "";
+ }
+ }
+ }
+
+ return { added, removed, formFields: formFieldsObj };
+}
+
+// =============================================================================
+// Color Conversion
+// =============================================================================
+
+/**
+ * Parse a CSS color string to normalized RGB values (0-1 range).
+ * Supports hex (#rgb, #rrggbb, #rrggbbaa) and rgb()/rgba() notation.
+ */
+export function cssColorToRgb(
+ color: string,
+): { r: number; g: number; b: number } | null {
+ // Parse hex colors
+ const hex = color.match(/^#([0-9a-f]{3,8})$/i);
+ if (hex) {
+ let h = hex[1];
+ if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
+ return {
+ r: parseInt(h.slice(0, 2), 16) / 255,
+ g: parseInt(h.slice(2, 4), 16) / 255,
+ b: parseInt(h.slice(4, 6), 16) / 255,
+ };
+ }
+ // Parse rgb/rgba
+ const rgbMatch = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
+ if (rgbMatch) {
+ return {
+ r: parseInt(rgbMatch[1]) / 255,
+ g: parseInt(rgbMatch[2]) / 255,
+ b: parseInt(rgbMatch[3]) / 255,
+ };
+ }
+ return null;
+}
+
+/** Default colors for each annotation type */
+export function defaultColor(type: PdfAnnotationDef["type"]): string {
+ switch (type) {
+ case "highlight":
+ return "#ffff00";
+ case "underline":
+ case "strikethrough":
+ return "#ff0000";
+ case "note":
+ return "#f5a623";
+ case "rectangle":
+ case "circle":
+ return "#0066cc";
+ case "line":
+ return "#333333";
+ case "freetext":
+ return "#333333";
+ case "stamp":
+ return "#cc0000";
+ case "image":
+ case "imported":
+ return "#00000000";
+ }
+}
+
+// =============================================================================
+// PDF Annotation Dict Creation (pdf-lib low-level API)
+// =============================================================================
+
+/**
+ * Create a PDF color array [r, g, b] from a CSS color string.
+ * Falls back to the default color for the annotation type.
+ */
+function makePdfColor(
+ context: PDFDocument["context"],
+ cssColor: string | undefined,
+ annotType: PdfAnnotationDef["type"],
+): PDFArray {
+ const rgb = cssColorToRgb(cssColor || defaultColor(annotType));
+ const { r, g, b } = rgb || { r: 0, g: 0, b: 0 };
+ const arr = PDFArray.withContext(context);
+ arr.push(PDFNumber.of(r));
+ arr.push(PDFNumber.of(g));
+ arr.push(PDFNumber.of(b));
+ return arr;
+}
+
+/** Create a PDF /Rect array [x1, y1, x2, y2] */
+function makePdfRect(
+ context: PDFDocument["context"],
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+): PDFArray {
+ const arr = PDFArray.withContext(context);
+ arr.push(PDFNumber.of(x));
+ arr.push(PDFNumber.of(y));
+ arr.push(PDFNumber.of(x + width));
+ arr.push(PDFNumber.of(y + height));
+ return arr;
+}
+
+/**
+ * Create /QuadPoints array for markup annotations (Highlight, Underline, StrikeOut).
+ * Each rect โ 8 numbers: x1,y1 (top-left), x2,y2 (top-right), x3,y3 (bottom-left), x4,y4 (bottom-right)
+ * PDF spec order: top-left, top-right, bottom-left, bottom-right
+ */
+function makeQuadPoints(
+ context: PDFDocument["context"],
+ rects: Rect[],
+): PDFArray {
+ const arr = PDFArray.withContext(context);
+ for (const r of rects) {
+ const x1 = r.x;
+ const y1 = r.y;
+ const x2 = r.x + r.width;
+ const y2 = r.y + r.height;
+ // QuadPoints order: top-left, top-right, bottom-left, bottom-right
+ arr.push(PDFNumber.of(x1));
+ arr.push(PDFNumber.of(y2)); // top-left
+ arr.push(PDFNumber.of(x2));
+ arr.push(PDFNumber.of(y2)); // top-right
+ arr.push(PDFNumber.of(x1));
+ arr.push(PDFNumber.of(y1)); // bottom-left
+ arr.push(PDFNumber.of(x2));
+ arr.push(PDFNumber.of(y1)); // bottom-right
+ }
+ return arr;
+}
+
+/** Compute bounding box of an array of rects */
+function boundingBox(rects: Rect[]): {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+} {
+ if (rects.length === 0) return { x: 0, y: 0, w: 0, h: 0 };
+ let minX = Infinity,
+ minY = Infinity,
+ maxX = -Infinity,
+ maxY = -Infinity;
+ for (const r of rects) {
+ minX = Math.min(minX, r.x);
+ minY = Math.min(minY, r.y);
+ maxX = Math.max(maxX, r.x + r.width);
+ maxY = Math.max(maxY, r.y + r.height);
+ }
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
+}
+
+/**
+ * Detect image MIME type from magic bytes.
+ */
+function detectImageMimeType(bytes: Uint8Array): "image/png" | "image/jpeg" {
+ // PNG magic: 0x89 0x50 0x4E 0x47
+ if (
+ bytes[0] === 0x89 &&
+ bytes[1] === 0x50 &&
+ bytes[2] === 0x4e &&
+ bytes[3] === 0x47
+ ) {
+ return "image/png";
+ }
+ // JPEG magic: 0xFF 0xD8
+ return "image/jpeg";
+}
+
+/**
+ * Add proper PDF annotation objects to a pdf-lib PDFDocument.
+ * Creates /Type /Annot dictionaries with correct /Subtype for each annotation type.
+ * These are real PDF annotations, editable in Acrobat/Preview.
+ */
+export async function addAnnotationDicts(
+ pdfDoc: PDFDocument,
+ annotations: PdfAnnotationDef[],
+): Promise {
+ const context = pdfDoc.context;
+ const pages = pdfDoc.getPages();
+
+ for (const def of annotations) {
+ // "imported" annotations are already in the source PDF โ never
+ // re-serialize them. Deletion is handled by buildAnnotatedPdfBytes'
+ // removedRefs strip; moving an imported annotation is still UI-only.
+ if (def.type === "imported") continue;
+
+ const pageIdx = def.page - 1;
+ if (pageIdx < 0 || pageIdx >= pages.length) continue;
+ const page = pages[pageIdx];
+
+ const dict = PDFDict.withContext(context);
+ dict.set(PDFName.of("Type"), PDFName.of("Annot"));
+
+ // Set /F (flags) = 4 (Print) so annotations print
+ dict.set(PDFName.of("F"), PDFNumber.of(4));
+
+ const color = makePdfColor(
+ context,
+ "color" in def ? def.color : undefined,
+ def.type,
+ );
+
+ switch (def.type) {
+ case "highlight":
+ case "underline":
+ case "strikethrough": {
+ const subtypeMap = {
+ highlight: "Highlight",
+ underline: "Underline",
+ strikethrough: "StrikeOut",
+ };
+ dict.set(PDFName.of("Subtype"), PDFName.of(subtypeMap[def.type]));
+
+ const bb = boundingBox(def.rects);
+ dict.set(
+ PDFName.of("Rect"),
+ makePdfRect(context, bb.x, bb.y, bb.w, bb.h),
+ );
+ dict.set(PDFName.of("QuadPoints"), makeQuadPoints(context, def.rects));
+ dict.set(PDFName.of("C"), color);
+
+ if (def.type === "highlight" && "content" in def && def.content) {
+ dict.set(PDFName.of("Contents"), PDFHexString.fromText(def.content));
+ }
+ break;
+ }
+
+ case "note": {
+ dict.set(PDFName.of("Subtype"), PDFName.of("Text"));
+ // Note icon is 24x24 points
+ dict.set(
+ PDFName.of("Rect"),
+ makePdfRect(context, def.x, def.y - 24, 24, 24),
+ );
+ dict.set(PDFName.of("Contents"), PDFHexString.fromText(def.content));
+ dict.set(PDFName.of("C"), color);
+ dict.set(PDFName.of("Name"), PDFName.of("Note"));
+ // Open = false (collapsed by default)
+ dict.set(PDFName.of("Open"), context.obj(false));
+ break;
+ }
+
+ case "rectangle":
+ case "circle": {
+ dict.set(
+ PDFName.of("Subtype"),
+ PDFName.of(def.type === "rectangle" ? "Square" : "Circle"),
+ );
+ dict.set(
+ PDFName.of("Rect"),
+ makePdfRect(context, def.x, def.y, def.width, def.height),
+ );
+ dict.set(PDFName.of("C"), color);
+
+ // Border style
+ const bs = PDFDict.withContext(context);
+ bs.set(PDFName.of("Type"), PDFName.of("Border"));
+ bs.set(PDFName.of("W"), PDFNumber.of(2));
+ bs.set(PDFName.of("S"), PDFName.of("S")); // Solid
+ dict.set(PDFName.of("BS"), bs);
+
+ if (def.fillColor) {
+ const fc = cssColorToRgb(def.fillColor);
+ if (fc) {
+ const icArr = PDFArray.withContext(context);
+ icArr.push(PDFNumber.of(fc.r));
+ icArr.push(PDFNumber.of(fc.g));
+ icArr.push(PDFNumber.of(fc.b));
+ dict.set(PDFName.of("IC"), icArr);
+ }
+ }
+ break;
+ }
+
+ case "line": {
+ dict.set(PDFName.of("Subtype"), PDFName.of("Line"));
+ // Rect is bounding box of the line
+ const lx = Math.min(def.x1, def.x2);
+ const ly = Math.min(def.y1, def.y2);
+ const lw = Math.abs(def.x2 - def.x1);
+ const lh = Math.abs(def.y2 - def.y1);
+ dict.set(
+ PDFName.of("Rect"),
+ makePdfRect(context, lx, ly, lw || 1, lh || 1),
+ );
+ // Line endpoints
+ const lineArr = PDFArray.withContext(context);
+ lineArr.push(PDFNumber.of(def.x1));
+ lineArr.push(PDFNumber.of(def.y1));
+ lineArr.push(PDFNumber.of(def.x2));
+ lineArr.push(PDFNumber.of(def.y2));
+ dict.set(PDFName.of("L"), lineArr);
+ dict.set(PDFName.of("C"), color);
+ break;
+ }
+
+ case "freetext": {
+ dict.set(PDFName.of("Subtype"), PDFName.of("FreeText"));
+ const fontSize = def.fontSize || 12;
+ // Estimate text dimensions
+ const textWidth = def.content.length * fontSize * 0.6;
+ const textHeight = fontSize * 1.4;
+ dict.set(
+ PDFName.of("Rect"),
+ makePdfRect(
+ context,
+ def.x,
+ def.y - textHeight,
+ textWidth,
+ textHeight,
+ ),
+ );
+ dict.set(PDFName.of("Contents"), PDFHexString.fromText(def.content));
+
+ // Default appearance string (DA) โ required for FreeText
+ const rgb = cssColorToRgb(def.color || defaultColor("freetext"));
+ const { r, g, b } = rgb || { r: 0, g: 0, b: 0 };
+ dict.set(
+ PDFName.of("DA"),
+ PDFString.of(`${r} ${g} ${b} rg /Helv ${fontSize} Tf`),
+ );
+ break;
+ }
+
+ case "stamp": {
+ dict.set(PDFName.of("Subtype"), PDFName.of("Stamp"));
+ const fontSize = 24;
+ // Match CSS padding: 4px top/bottom, 12px left/right
+ const padX = 12;
+ const padY = 4;
+
+ dict.set(PDFName.of("C"), color);
+
+ // Use a non-standard /Name so viewers like Preview.app don't
+ // substitute their own built-in stamp graphic
+ dict.set(PDFName.of("Name"), PDFName.of("#custom"));
+ dict.set(PDFName.of("Contents"), PDFHexString.fromText(def.label));
+
+ // Create a simple appearance stream so the stamp text is visible
+ const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
+ const actualTextWidth = font.widthOfTextAtSize(def.label, fontSize);
+ const apRectW = actualTextWidth + padX * 2;
+ const apRectH = fontSize + padY * 2;
+
+ // Set rect to match appearance stream dimensions
+ dict.set(
+ PDFName.of("Rect"),
+ makePdfRect(context, def.x, def.y - apRectH, apRectW, apRectH),
+ );
+
+ // Build appearance stream content matching the CSS rendering:
+ // - 3pt border stroke
+ // - 0.6 opacity
+ // - text baseline positioned to visually center in the box
+ const rgb = cssColorToRgb(def.color || defaultColor("stamp"));
+ const { r, g, b } = rgb || { r: 0.8, g: 0, b: 0 };
+ // Font descent โ 20% of font size for Helvetica
+ const descent = fontSize * 0.2;
+ const textBaselineY = padY + descent;
+ const streamContent = [
+ `/GS0 gs`, // match CSS opacity: 0.6
+ `${r} ${g} ${b} RG`, // stroke color
+ `${r} ${g} ${b} rg`, // fill color
+ `3 w`, // line width (matches CSS border: 3px)
+ `1.5 1.5 ${apRectW - 3} ${apRectH - 3} re S`, // border rect inset by half line width
+ `BT`,
+ `/F1 ${fontSize} Tf`,
+ `${padX} ${textBaselineY} Td`,
+ `(${def.label.replace(/[()\\]/g, "\\$&")}) Tj`,
+ `ET`,
+ ].join("\n");
+
+ // Compute rotation matrix if specified
+ let rotationMatrix: number[] | undefined;
+ if (def.rotation) {
+ const rad = (def.rotation * Math.PI) / 180;
+ const cos = Math.cos(rad);
+ const sin = Math.sin(rad);
+ // Rotation matrix around the center of the bounding box
+ const cx = apRectW / 2;
+ const cy = apRectH / 2;
+ // Translate to origin, rotate, translate back: [cos sin -sin cos tx ty]
+ const tx = cx - cos * cx + sin * cy;
+ const ty = cy - sin * cx - cos * cy;
+ rotationMatrix = [cos, sin, -sin, cos, tx, ty];
+ }
+
+ // Create ExtGState for opacity (ca = fill opacity, CA = stroke opacity)
+ const gsDict = PDFDict.withContext(context);
+ gsDict.set(PDFName.of("Type"), PDFName.of("ExtGState"));
+ gsDict.set(PDFName.of("ca"), PDFNumber.of(0.6));
+ gsDict.set(PDFName.of("CA"), PDFNumber.of(0.6));
+ const gsRef = context.register(gsDict);
+
+ // Build Resources dict with Font and ExtGState
+ const stampResDict = PDFDict.withContext(context);
+ const fontResDict = PDFDict.withContext(context);
+ fontResDict.set(PDFName.of("F1"), font.ref);
+ stampResDict.set(PDFName.of("Font"), fontResDict);
+ const gsResDict = PDFDict.withContext(context);
+ gsResDict.set(PDFName.of("GS0"), gsRef);
+ stampResDict.set(PDFName.of("ExtGState"), gsResDict);
+
+ // Create the appearance stream
+ const apStream = context.flateStream(streamContent, {
+ Type: "XObject",
+ Subtype: "Form",
+ BBox: [0, 0, apRectW, apRectH],
+ ...(rotationMatrix ? { Matrix: rotationMatrix } : {}),
+ });
+
+ // Attach Resources to the stream dict
+ const apStreamDict = (apStream as any).dict || apStream;
+ apStreamDict.set(PDFName.of("Resources"), stampResDict);
+
+ const apRef = context.register(apStream);
+
+ // Create AP dictionary
+ const apDict = PDFDict.withContext(context);
+ apDict.set(PDFName.of("N"), apRef);
+ dict.set(PDFName.of("AP"), apDict);
+ break;
+ }
+
+ case "image": {
+ dict.set(PDFName.of("Subtype"), PDFName.of("Stamp"));
+ dict.set(
+ PDFName.of("Rect"),
+ makePdfRect(context, def.x, def.y, def.width, def.height),
+ );
+ dict.set(PDFName.of("Name"), PDFName.of("Image"));
+
+ if (def.imageData) {
+ // Detect mime type from magic bytes or use provided mimeType
+ const imgBytes = base64ToUint8Array(def.imageData);
+ const mime = def.mimeType || detectImageMimeType(imgBytes);
+ const embeddedImage =
+ mime === "image/jpeg"
+ ? await pdfDoc.embedJpg(imgBytes)
+ : await pdfDoc.embedPng(imgBytes);
+
+ const imgW = def.width;
+ const imgH = def.height;
+
+ // Build appearance stream that draws the image
+ const streamContent = `q ${imgW} 0 0 ${imgH} 0 0 cm /Img Do Q`;
+
+ // Compute rotation matrix if specified
+ let rotationMatrix: number[] | undefined;
+ if (def.rotation) {
+ const rad = (def.rotation * Math.PI) / 180;
+ const cos = Math.cos(rad);
+ const sin = Math.sin(rad);
+ const cx = imgW / 2;
+ const cy = imgH / 2;
+ const tx = cx - cos * cx + sin * cy;
+ const ty = cy - sin * cx - cos * cy;
+ rotationMatrix = [cos, sin, -sin, cos, tx, ty];
+ }
+
+ // Create Resources dict with XObject containing the image
+ const xObjDict = PDFDict.withContext(context);
+ xObjDict.set(PDFName.of("Img"), embeddedImage.ref);
+ const resDict = PDFDict.withContext(context);
+ resDict.set(PDFName.of("XObject"), xObjDict);
+
+ const apStream = context.flateStream(streamContent, {
+ Type: "XObject",
+ Subtype: "Form",
+ BBox: [0, 0, imgW, imgH],
+ ...(rotationMatrix ? { Matrix: rotationMatrix } : {}),
+ });
+
+ // Attach Resources to the stream dict
+ const apStreamDict = (apStream as any).dict || apStream;
+ apStreamDict.set(PDFName.of("Resources"), resDict);
+
+ const apRef = context.register(apStream);
+
+ const apDict = PDFDict.withContext(context);
+ apDict.set(PDFName.of("N"), apRef);
+ dict.set(PDFName.of("AP"), apDict);
+ }
+ break;
+ }
+ }
+
+ // Register the annotation dict and add to page
+ const annotRef = context.register(dict);
+ page.node.addAnnot(annotRef);
+ }
+}
+
+/**
+ * Select a radio-style button group by widget on-value, bypassing pdf-lib's
+ * type-level guards. Used when pdf-lib classifies a radio as `PDFCheckBox`
+ * (PDF lacks the /Ff Radio bit) โ `check()` would always pick the first
+ * widget. Mirrors `PDFAcroRadioButton.setValue` minus its `onValues` throw.
+ */
+function setButtonGroupValue(
+ field: PDFCheckBox | PDFRadioGroup,
+ onValue: string,
+): void {
+ const acro = field.acroField;
+ const off = PDFName.of("Off");
+ const widgets = acro.getWidgets();
+ // Match by PDFName identity (pdf-lib interns names) โ the viewer stored
+ // pdf.js's buttonValue, which IS the widget's /AP /N on-state name.
+ let target = onValue && onValue !== "Off" ? PDFName.of(onValue) : off;
+ if (
+ target !== off &&
+ !widgets.some((w: { getOnValue(): PDFName | undefined }) => {
+ return w.getOnValue() === target;
+ })
+ ) {
+ // No widget has this on-state โ leave as-is rather than corrupt /V.
+ return;
+ }
+ acro.dict.set(PDFName.of("V"), target);
+ for (const w of widgets) {
+ const on = (w as { getOnValue(): PDFName | undefined }).getOnValue();
+ (w as { setAppearanceState(s: PDFName): void }).setAppearanceState(
+ on === target ? target : off,
+ );
+ }
+}
+
+/**
+ * Recover the PDF object reference from an annotation id assigned by
+ * `makeAnnotationId`. Handles both `pdf--` (from `ann.ref`) and
+ * `pdf-R` (from pdf.js's string `ann.id`, gen always 0). Returns null for
+ * page-index fallback ids โ those have no stable ref to remove by.
+ */
+export function parseAnnotationRef(
+ id: string,
+): { objectNumber: number; generationNumber: number } | null {
+ let m = /^pdf-(\d+)-(\d+)$/.exec(id);
+ if (m) return { objectNumber: +m[1], generationNumber: +m[2] };
+ m = /^pdf-(\d+)R$/.exec(id);
+ if (m) return { objectNumber: +m[1], generationNumber: 0 };
+ return null;
+}
+
+/**
+ * Build annotated PDF bytes from the original document.
+ * Applies user annotations and form fills, removes baseline annotations the
+ * user deleted, returns Uint8Array of the new PDF.
+ */
+export async function buildAnnotatedPdfBytes(
+ pdfBytes: Uint8Array,
+ annotations: PdfAnnotationDef[],
+ formFields: Map,
+ removedRefs: { objectNumber: number; generationNumber: number }[] = [],
+): Promise {
+ const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true });
+
+ // Strip baseline annotations the user deleted. We match on object number +
+ // generation against each page's /Annots array entries (PDFRef). We don't
+ // know which page they were on, so scan every page โ /Annots arrays are
+ // small and this only runs at save time.
+ if (removedRefs.length > 0) {
+ const wanted = new Set(
+ removedRefs.map((r) => `${r.objectNumber} ${r.generationNumber}`),
+ );
+ for (const page of pdfDoc.getPages()) {
+ const annots = page.node.Annots();
+ if (!annots) continue;
+ // Walk backwards so .remove(idx) doesn't shift unprocessed entries.
+ for (let i = annots.size() - 1; i >= 0; i--) {
+ const ref = annots.get(i) as {
+ objectNumber?: number;
+ generationNumber?: number;
+ };
+ if (
+ ref?.objectNumber !== undefined &&
+ wanted.has(`${ref.objectNumber} ${ref.generationNumber ?? 0}`)
+ ) {
+ annots.remove(i);
+ }
+ }
+ }
+ }
+
+ // Add proper PDF annotation objects
+ await addAnnotationDicts(pdfDoc, annotations);
+
+ // Apply form fills. Dispatch on actual field type โ getTextField(name) throws
+ // for dropdowns/radios, so we look up the generic field and instanceof it.
+ // Each field is wrapped in its own try/catch: pdf-lib can throw on
+ // length-constrained text, radios whose buttonValue maps to neither label
+ // nor index, checkboxes missing a /Yes appearance, etc. One bad field must
+ // not abort the rest of the loop (regressed in #577 when the inner catch
+ // was dropped along with the type-specific getters).
+ if (formFields.size > 0) {
+ let form: PDFForm | undefined;
+ try {
+ form = pdfDoc.getForm();
+ } catch {
+ // No AcroForm in this PDF
+ }
+ if (form) {
+ for (const [name, value] of formFields) {
+ try {
+ const field = form.getFieldMaybe(name);
+ if (!field) continue;
+
+ if (field instanceof PDFCheckBox) {
+ const widgets = field.acroField.getWidgets();
+ if (typeof value === "string" && widgets.length > 1) {
+ // Multi-widget "checkbox" with a string value = pdf-lib
+ // misclassified a radio group (PDF lacks the /Ff Radio flag).
+ // The viewer stored pdf.js's buttonValue (the widget's on-state
+ // name, e.g. "0"/"1"); check()/uncheck() would hit the FIRST
+ // widget regardless. Write /V and per-widget /AS directly.
+ setButtonGroupValue(field, value);
+ } else {
+ // Single-widget (real) checkbox. Viewer normally stores boolean,
+ // but be liberal: any truthy non-"Off" string counts as checked.
+ const on =
+ value === true ||
+ (typeof value === "string" && value !== "" && value !== "Off");
+ if (on) field.check();
+ else field.uncheck();
+ }
+ } else if (field instanceof PDFRadioGroup) {
+ // The viewer stores pdf.js's buttonValue, which for PDFs with an
+ // /Opt array is a numeric index ("0","1","2") rather than the
+ // option label pdf-lib's select() expects. Try the label first,
+ // then fall back to indexing into getOptions().
+ const opts = field.getOptions();
+ const s = String(value);
+ if (opts.includes(s)) {
+ field.select(s);
+ } else {
+ const idx = Number(s);
+ if (Number.isInteger(idx) && idx >= 0 && idx < opts.length) {
+ field.select(opts[idx]);
+ }
+ // else: value is neither label nor index โ leave unset
+ }
+ } else if (field instanceof PDFDropdown) {
+ // select() auto-enables edit mode for values outside getOptions(),
+ // so this works for both enumerated and free-text combos.
+ field.select(String(value));
+ } else if (field instanceof PDFOptionList) {
+ // Viewer stores multiselect listboxes as a comma-joined string
+ // (pdf.js's annotationStorage value for /Ch is string|string[];
+ // our Map flattens arrays). Only select
+ // values that are actually in the option list โ pdf-lib throws
+ // on unknowns and there's no edit-mode fallback like Dropdown.
+ const opts = field.getOptions();
+ const wanted = String(value)
+ .split(",")
+ .map((s) => s.trim())
+ .filter((s) => opts.includes(s));
+ if (wanted.length > 0) field.select(wanted);
+ else field.clear();
+ } else if (field instanceof PDFTextField) {
+ field.setText(String(value));
+ }
+ // PDFButton, PDFSignature: no fill_form support yet
+ } catch (err) {
+ // Skip this field; carry on with the rest. Surfacing per-field
+ // failures is the caller's job (see fill_form result), not save's.
+ console.warn(`buildAnnotatedPdfBytes: skipped field "${name}":`, err);
+ }
+ }
+ // Some PDFs ship widgets with no on-state appearance stream (no
+ // /AP/N/). pdf-lib's check()/select()/setText() set /V and /AS
+ // but don't synthesize the missing appearance, so other readers
+ // (Preview, Acrobat) show the field as if unchanged. This generates a
+ // default appearance for any field marked dirty above.
+ try {
+ form.updateFieldAppearances();
+ } catch (err) {
+ console.warn("buildAnnotatedPdfBytes: updateFieldAppearances:", err);
+ }
+ }
+ }
+
+ return pdfDoc.save();
+}
+
+// =============================================================================
+// PDF.js Annotation Import
+// =============================================================================
+
+/**
+ * PDF.js annotation type constants (from AnnotationType enum).
+ * We only import types we support.
+ */
+const PDFJS_TYPE_MAP: Record = {
+ 1: "note", // TEXT
+ 3: "freetext", // FREETEXT
+ 4: "line", // LINE
+ 5: "rectangle", // SQUARE
+ 6: "circle", // CIRCLE
+ 9: "highlight", // HIGHLIGHT
+ 10: "underline", // UNDERLINE
+ 12: "strikethrough", // STRIKEOUT
+ 13: "stamp", // STAMP
+};
+
+/**
+ * Convert a PDF.js annotation color array [r, g, b] (0-255) to CSS hex string.
+ */
+function pdfjsColorToHex(
+ color: Uint8ClampedArray | number[] | null | undefined,
+): string | undefined {
+ if (!color || color.length < 3) return undefined;
+ const r = Math.round(color[0]);
+ const g = Math.round(color[1]);
+ const b = Math.round(color[2]);
+ const hex = ((r << 16) | (g << 8) | b).toString(16).padStart(6, "0");
+ return `#${hex}`;
+}
+
+/**
+ * Convert a PDF.js annotation rect [x1, y1, x2, y2] to our Rect format.
+ */
+function pdfjsRectToRect(rect: number[]): Rect {
+ return {
+ x: Math.min(rect[0], rect[2]),
+ y: Math.min(rect[1], rect[3]),
+ width: Math.abs(rect[2] - rect[0]),
+ height: Math.abs(rect[3] - rect[1]),
+ };
+}
+
+/**
+ * Build a stable annotation ID from pdf.js annotation data.
+ * Uses the annotation's ref (PDF object reference) if available,
+ * otherwise falls back to page + index.
+ */
+function makeAnnotationId(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ann: any,
+ pageNum: number,
+ index: number,
+): string {
+ if (ann.ref) {
+ return `pdf-${ann.ref.num}-${ann.ref.gen}`;
+ }
+ if (ann.id) {
+ return `pdf-${ann.id}`;
+ }
+ return `pdf-${pageNum}-${index}`;
+}
+
+/**
+ * Convert a single PDF.js annotation object to our PdfAnnotationDef format.
+ * Returns null for unsupported annotation types.
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function importPdfjsAnnotation(
+ ann: any,
+ pageNum: number,
+ index: number,
+): PdfAnnotationDef | null {
+ // Skip form widgets (they're handled separately by AnnotationLayer) and
+ // auxiliary types that aren't user-visible markup:
+ // 2 = Link (navigational, AnnotationLayer handles the click target)
+ // 16 = Popup (the speech-bubble UI for a parent annotation, not content)
+ if (
+ ann.annotationType === 20 ||
+ ann.annotationType === 2 ||
+ ann.annotationType === 16
+ ) {
+ return null;
+ }
+
+ const id = makeAnnotationId(ann, pageNum, index);
+ const color = pdfjsColorToHex(ann.color);
+ const ourType = PDFJS_TYPE_MAP[ann.annotationType];
+
+ // Anything we don't model โ and stamps whose visual is an appearance
+ // stream we can't reproduce as a text label โ are kept as "imported":
+ // a placement-only record that the renderer fills with the rasterized
+ // appearance from pdf.js's annotationCanvasMap. This keeps Ink, Polygon,
+ // image-signature stamps, etc. visible AND selectable in our layer.
+ const importAsBitmap = !ourType || (ourType === "stamp" && ann.hasAppearance);
+ if (importAsBitmap) {
+ if (!ann.rect) return null;
+ const r = pdfjsRectToRect(ann.rect);
+ return {
+ type: "imported",
+ id,
+ page: pageNum,
+ x: r.x,
+ y: r.y,
+ width: r.width,
+ height: r.height,
+ pdfjsId: String(ann.id ?? ""),
+ subtype: String(ann.subtype ?? `type${ann.annotationType}`),
+ };
+ }
+
+ switch (ourType) {
+ case "highlight":
+ case "underline":
+ case "strikethrough": {
+ // pdf.js emits quadPoints as a FLAT Float32Array [x1,y1,...,x4,y4, โฆ]
+ // (8 numbers per quad), NOT as nested arrays. Iterating it yields
+ // numbers, so the old `for (const qp of โฆ) if (qp.length>=8)` never
+ // matched and every quad-based annotation was dropped (#506).
+ const rects: Rect[] = [];
+ const qp = ann.quadPoints as ArrayLike | undefined;
+ if (qp && qp.length >= 8) {
+ for (let i = 0; i + 8 <= qp.length; i += 8) {
+ const xs = [qp[i], qp[i + 2], qp[i + 4], qp[i + 6]];
+ const ys = [qp[i + 1], qp[i + 3], qp[i + 5], qp[i + 7]];
+ const minX = Math.min(...xs);
+ const minY = Math.min(...ys);
+ rects.push({
+ x: minX,
+ y: minY,
+ width: Math.max(...xs) - minX,
+ height: Math.max(...ys) - minY,
+ });
+ }
+ }
+ if (rects.length === 0 && ann.rect) {
+ rects.push(pdfjsRectToRect(ann.rect));
+ }
+ if (rects.length === 0) return null;
+
+ const base = { id, page: pageNum, rects, color };
+ if (ourType === "highlight") {
+ return {
+ ...base,
+ type: "highlight",
+ content: ann.contentsObj?.str || ann.contents || undefined,
+ };
+ }
+ return { ...base, type: ourType } as PdfAnnotationDef;
+ }
+
+ case "note": {
+ if (!ann.rect) return null;
+ const rect = pdfjsRectToRect(ann.rect);
+ return {
+ type: "note",
+ id,
+ page: pageNum,
+ x: rect.x,
+ y: rect.y + rect.height, // PDF.js rect y is bottom; note uses top point
+ content: ann.contentsObj?.str || ann.contents || "",
+ color,
+ };
+ }
+
+ case "rectangle":
+ case "circle": {
+ if (!ann.rect) return null;
+ const rect = pdfjsRectToRect(ann.rect);
+ return {
+ type: ourType,
+ id,
+ page: pageNum,
+ x: rect.x,
+ y: rect.y,
+ width: rect.width,
+ height: rect.height,
+ color,
+ } as PdfAnnotationDef;
+ }
+
+ case "line": {
+ // PDF.js LineAnnotation provides lineCoordinates [x1, y1, x2, y2]
+ if (ann.lineCoordinates && ann.lineCoordinates.length >= 4) {
+ return {
+ type: "line",
+ id,
+ page: pageNum,
+ x1: ann.lineCoordinates[0],
+ y1: ann.lineCoordinates[1],
+ x2: ann.lineCoordinates[2],
+ y2: ann.lineCoordinates[3],
+ color,
+ };
+ }
+ if (!ann.rect) return null;
+ const lineRect = pdfjsRectToRect(ann.rect);
+ return {
+ type: "line",
+ id,
+ page: pageNum,
+ x1: lineRect.x,
+ y1: lineRect.y,
+ x2: lineRect.x + lineRect.width,
+ y2: lineRect.y + lineRect.height,
+ color,
+ };
+ }
+
+ case "freetext": {
+ if (!ann.rect) return null;
+ const rect = pdfjsRectToRect(ann.rect);
+ return {
+ type: "freetext",
+ id,
+ page: pageNum,
+ x: rect.x,
+ y: rect.y + rect.height,
+ content: ann.contentsObj?.str || ann.contents || "",
+ fontSize: ann.fontSize || 12,
+ color,
+ };
+ }
+
+ case "stamp": {
+ if (!ann.rect) return null;
+ const rect = pdfjsRectToRect(ann.rect);
+ return {
+ type: "stamp",
+ id,
+ page: pageNum,
+ x: rect.x,
+ y: rect.y + rect.height,
+ label: ann.name || ann.contentsObj?.str || ann.contents || "STAMP",
+ color,
+ };
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Convert base64 string to Uint8Array.
+ */
+export function base64ToUint8Array(base64: string): Uint8Array {
+ const binary = atob(base64);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes;
+}
+
+/**
+ * Convert Uint8Array to base64 string.
+ */
+export function uint8ArrayToBase64(bytes: Uint8Array): string {
+ let binary = "";
+ for (let i = 0; i < bytes.length; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary);
+}
diff --git a/examples/pdf-server/src/viewer-state.ts b/examples/pdf-server/src/viewer-state.ts
new file mode 100644
index 0000000..0c5399b
--- /dev/null
+++ b/examples/pdf-server/src/viewer-state.ts
@@ -0,0 +1,50 @@
+/**
+ * Shared mutable state between mcp-app.ts and annotation-panel.ts.
+ *
+ * Only containers (Map/Set/Array) and DOM refs live here โ both modules
+ * mutate their CONTENTS, never reassign the binding. Scalars that cross
+ * the boundary use getter callbacks (see annotation-panel.ts PanelDeps)
+ * to avoid wrapping every `currentPage` read in mcp-app.ts.
+ */
+
+import type { PdfAnnotationDef } from "./pdf-annotations.js";
+
+export interface TrackedAnnotation {
+ def: PdfAnnotationDef;
+ elements: HTMLElement[];
+}
+
+export interface EditEntry {
+ type: "update" | "add" | "remove";
+ id: string;
+ before: PdfAnnotationDef | null;
+ after: PdfAnnotationDef | null;
+}
+
+// Annotation state
+export const annotationMap = new Map();
+export const formFieldValues = new Map();
+/** Form field values stored in the PDF file itself (baseline for diff computation). */
+export const pdfBaselineFormValues = new Map();
+
+// Selection & interaction state
+export const selectedAnnotationIds = new Set();
+
+// Undo/Redo
+export const undoStack: EditEntry[] = [];
+export const redoStack: EditEntry[] = [];
+
+// PDF.js form field name โ annotation IDs mapping (for annotationStorage)
+export const fieldNameToIds = new Map();
+// PDF.js form field name โ page number mapping
+export const fieldNameToPage = new Map();
+// PDF.js form field name โ human-readable label (from PDF TU / alternativeText)
+export const fieldNameToLabel = new Map();
+// PDF.js form field name โ intrinsic order index (page, then top-to-bottom Y position)
+export const fieldNameToOrder = new Map();
+
+// Shared DOM refs (read by both modules)
+export const searchBarEl = document.getElementById("search-bar")!;
+export const formLayerEl = document.getElementById(
+ "form-layer",
+) as HTMLDivElement;
diff --git a/examples/pdf-server/tsconfig.json b/examples/pdf-server/tsconfig.json
index 0501827..2fe7e62 100644
--- a/examples/pdf-server/tsconfig.json
+++ b/examples/pdf-server/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client", "bun"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/pdf-server/tsconfig.server.json b/examples/pdf-server/tsconfig.server.json
index 05ddd8e..27eb28f 100644
--- a/examples/pdf-server/tsconfig.server.json
+++ b/examples/pdf-server/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/examples/qr-server/README.md b/examples/qr-server/README.md
index 9348bbc..bf792e7 100644
--- a/examples/qr-server/README.md
+++ b/examples/qr-server/README.md
@@ -2,7 +2,7 @@
A minimal Python MCP server that generates customizable QR codes with an interactive view UI.
-
+
## MCP Client Configuration
diff --git a/examples/qr-server/package.json b/examples/qr-server/package.json
index b72db3e..425728e 100644
--- a/examples/qr-server/package.json
+++ b/examples/qr-server/package.json
@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/server-qr",
- "version": "1.1.2",
+ "version": "2.0.0",
"private": true,
"scripts": {
"start": "uv run server.py",
@@ -8,6 +8,6 @@
"build": "echo 'No build step needed for Python server'"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0"
+ "@modelcontextprotocol/ext-apps": "^2.0.0"
}
}
diff --git a/examples/qr-server/server.py b/examples/qr-server/server.py
index d282d1a..c1c302e 100755
--- a/examples/qr-server/server.py
+++ b/examples/qr-server/server.py
@@ -26,7 +26,7 @@ VIEW_URI = "ui://qr-server/view.html"
HOST = os.environ.get("HOST", "0.0.0.0") # 0.0.0.0 for Docker compatibility
PORT = int(os.environ.get("PORT", "3001"))
-mcp = FastMCP("QR Code Server", stateless_http=True)
+mcp = FastMCP("QR Code Server", stateless_http=True, host=HOST, port=PORT)
# Embedded View HTML for self-contained usage (uv run or unbundled)
EMBEDDED_VIEW_HTML = """
diff --git a/examples/quickstart/main.ts b/examples/quickstart/main.ts
index 06895a4..7f370a8 100644
--- a/examples/quickstart/main.ts
+++ b/examples/quickstart/main.ts
@@ -1,7 +1,7 @@
-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 { createMcpExpressApp } from "@modelcontextprotocol/express";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
@@ -21,7 +21,7 @@ export async function startStreamableHTTPServer(
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
diff --git a/examples/quickstart/package.json b/examples/quickstart/package.json
index 13353e1..57f8444 100644
--- a/examples/quickstart/package.json
+++ b/examples/quickstart/package.json
@@ -1,30 +1,33 @@
{
"name": "@modelcontextprotocol/quickstart",
- "version": "1.1.2",
+ "version": "2.0.0",
"type": "module",
"private": true,
"description": "Quickstart MCP App Server example",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/quickstart"
},
"license": "MIT",
"scripts": {
"build": "tsc --noEmit && tsc -p tsconfig.server.json && cross-env INPUT=mcp-app.html vite build",
- "start": "concurrently \"cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch\" \"tsx watch main.ts\""
+ "start": "concurrently --raw \"cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch\" \"tsx watch main.ts\""
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
- "express": "^5.1.0"
+ "express": "^5.1.0",
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
- "concurrently": "^9.2.1",
"@types/express": "^5.0.0",
"@types/node": "22.19.5",
+ "concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
diff --git a/examples/quickstart/server.ts b/examples/quickstart/server.ts
index eb3c3b1..611d0f0 100644
--- a/examples/quickstart/server.ts
+++ b/examples/quickstart/server.ts
@@ -3,9 +3,10 @@ import {
registerAppTool,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { McpServer } from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
+import { z } from "zod";
const DIST_DIR = path.join(import.meta.dirname, "dist");
@@ -30,7 +31,7 @@ export function createServer(): McpServer {
{
title: "Get Time",
description: "Returns the current server time.",
- inputSchema: {},
+ inputSchema: z.object({}),
_meta: { ui: { resourceUri } }, // Links this tool to its UI resource
},
async () => {
diff --git a/examples/quickstart/tsconfig.json b/examples/quickstart/tsconfig.json
index 6c553b5..d6a42ce 100644
--- a/examples/quickstart/tsconfig.json
+++ b/examples/quickstart/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/quickstart/tsconfig.server.json b/examples/quickstart/tsconfig.server.json
index 7e65f5f..72552e2 100644
--- a/examples/quickstart/tsconfig.server.json
+++ b/examples/quickstart/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/examples/quickstart/vite.config.ts b/examples/quickstart/vite.config.ts
index 6ff6d99..d34af53 100644
--- a/examples/quickstart/vite.config.ts
+++ b/examples/quickstart/vite.config.ts
@@ -1,4 +1,4 @@
-import { defineConfig } from "vite";
+import { createLogger, defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
const INPUT = process.env.INPUT;
@@ -8,7 +8,14 @@ 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,
diff --git a/examples/say-server/package.json b/examples/say-server/package.json
index edc4a55..cc12249 100644
--- a/examples/say-server/package.json
+++ b/examples/say-server/package.json
@@ -1,11 +1,11 @@
{
"name": "@modelcontextprotocol/server-say",
- "version": "1.1.2",
+ "version": "2.0.0",
"private": true,
"description": "Streaming TTS MCP App Server with karaoke-style text highlighting",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/say-server"
},
"license": "MIT",
@@ -15,6 +15,6 @@
"build": "echo 'No build step needed for Python server'"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0"
+ "@modelcontextprotocol/ext-apps": "^2.0.0"
}
}
diff --git a/examples/scenario-modeler-server/README.md b/examples/scenario-modeler-server/README.md
index c934150..8b4c18e 100644
--- a/examples/scenario-modeler-server/README.md
+++ b/examples/scenario-modeler-server/README.md
@@ -4,9 +4,9 @@ A React-based demo MCP App that lets users adjust SaaS business parameters and s
diff --git a/examples/scenario-modeler-server/main.ts b/examples/scenario-modeler-server/main.ts
index dbd3ab6..cc20e63 100644
--- a/examples/scenario-modeler-server/main.ts
+++ b/examples/scenario-modeler-server/main.ts
@@ -4,10 +4,10 @@
* Or: node dist/index.js [--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 { createMcpExpressApp } from "@modelcontextprotocol/express";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
@@ -27,7 +27,7 @@ export async function startStreamableHTTPServer(
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
diff --git a/examples/scenario-modeler-server/package.json b/examples/scenario-modeler-server/package.json
index af91923..e6f37c0 100644
--- a/examples/scenario-modeler-server/package.json
+++ b/examples/scenario-modeler-server/package.json
@@ -1,11 +1,11 @@
{
"name": "@modelcontextprotocol/server-scenario-modeler",
- "version": "1.1.2",
+ "version": "2.0.0",
"type": "module",
"description": "Financial scenario modeling MCP App Server",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/scenario-modeler-server"
},
"license": "MIT",
@@ -17,23 +17,25 @@
"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 server.ts --stdio",
+ "serve:stdio": "bun main.ts --stdio",
"start": "npm run start:http",
"start:http": "cross-env NODE_ENV=development npm run build && npm run serve:http",
- "start:stdio": "cross-env NODE_ENV=development npm run build && npm run serve:stdio",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
"dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve:http\"",
"prepublishOnly": "npm run build",
"serve": "bun --watch main.ts"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"chart.js": "^4.4.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
diff --git a/examples/scenario-modeler-server/server.ts b/examples/scenario-modeler-server/server.ts
index b59851c..c5fa05d 100644
--- a/examples/scenario-modeler-server/server.ts
+++ b/examples/scenario-modeler-server/server.ts
@@ -3,11 +3,11 @@ import {
registerAppResource,
registerAppTool,
} from "@modelcontextprotocol/ext-apps/server";
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type {
- CallToolResult,
- ReadResourceResult,
-} from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
@@ -305,8 +305,8 @@ export function createServer(): McpServer {
title: "Get Scenario Data",
description:
"Returns SaaS scenario templates and optionally computes custom projections for given inputs",
- inputSchema: GetScenarioDataInputSchema.shape,
- outputSchema: GetScenarioDataOutputSchema.shape,
+ inputSchema: GetScenarioDataInputSchema,
+ outputSchema: GetScenarioDataOutputSchema,
_meta: { ui: { resourceUri } },
},
async (args: {
diff --git a/examples/scenario-modeler-server/tsconfig.json b/examples/scenario-modeler-server/tsconfig.json
index fc3c210..3ecbdad 100644
--- a/examples/scenario-modeler-server/tsconfig.json
+++ b/examples/scenario-modeler-server/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/scenario-modeler-server/tsconfig.server.json b/examples/scenario-modeler-server/tsconfig.server.json
index 05ddd8e..27eb28f 100644
--- a/examples/scenario-modeler-server/tsconfig.server.json
+++ b/examples/scenario-modeler-server/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/examples/shadertoy-server/README.md b/examples/shadertoy-server/README.md
index bcc9f1b..e1390f6 100644
--- a/examples/shadertoy-server/README.md
+++ b/examples/shadertoy-server/README.md
@@ -4,9 +4,9 @@ A demo MCP App that renders [ShaderToy](https://www.shadertoy.com/)-compatible G
diff --git a/examples/shadertoy-server/main.ts b/examples/shadertoy-server/main.ts
index ac8f6ad..7e388f0 100644
--- a/examples/shadertoy-server/main.ts
+++ b/examples/shadertoy-server/main.ts
@@ -4,10 +4,10 @@
* Or: node dist/index.js [--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 { createMcpExpressApp } from "@modelcontextprotocol/express";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
@@ -27,7 +27,7 @@ export async function startStreamableHTTPServer(
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
diff --git a/examples/shadertoy-server/package.json b/examples/shadertoy-server/package.json
index 7829dba..aec56c6 100644
--- a/examples/shadertoy-server/package.json
+++ b/examples/shadertoy-server/package.json
@@ -1,11 +1,11 @@
{
"name": "@modelcontextprotocol/server-shadertoy",
- "version": "1.1.2",
+ "version": "2.0.0",
"type": "module",
"description": "MCP App Server example for rendering ShaderToy-compatible GLSL shaders",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/shadertoy-server"
},
"license": "MIT",
@@ -19,16 +19,18 @@
"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 && npm run serve:stdio",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
"dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"",
"prepublishOnly": "npm run build"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
diff --git a/examples/shadertoy-server/server.ts b/examples/shadertoy-server/server.ts
index a4bfe9e..50c27bc 100644
--- a/examples/shadertoy-server/server.ts
+++ b/examples/shadertoy-server/server.ts
@@ -3,11 +3,11 @@ import {
registerAppTool,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type {
- CallToolResult,
- ReadResourceResult,
-} from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
@@ -63,7 +63,9 @@ LIMITATIONS - Do NOT use:
- VR features (mainVR not available)
For procedural noise:
- float hash(vec2 p) { return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453); }`;
+ float hash(vec2 p) { return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453); }
+
+The widget is interactive and exposes tools for updating shader source code and querying compilation status. Compilation errors are sent to model context automatically.`;
const DEFAULT_FRAGMENT_SHADER = `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
diff --git a/examples/shadertoy-server/src/mcp-app.css b/examples/shadertoy-server/src/mcp-app.css
index 0449ea5..0d5b6f1 100644
--- a/examples/shadertoy-server/src/mcp-app.css
+++ b/examples/shadertoy-server/src/mcp-app.css
@@ -1,4 +1,5 @@
-html, body {
+html,
+body {
margin: 0;
width: 100%;
height: 100%;
@@ -23,6 +24,7 @@ html, body {
width: 100%;
height: 100%;
display: block;
+ touch-action: none;
}
#canvas.hidden {
@@ -68,7 +70,9 @@ html, body {
display: none; /* Hidden by default, shown when fullscreen available */
align-items: center;
justify-content: center;
- transition: background 0.2s, opacity 0.2s;
+ transition:
+ background 0.2s,
+ opacity 0.2s;
opacity: 0; /* Initially invisible, shown on View hover */
z-index: 100;
}
diff --git a/examples/shadertoy-server/src/mcp-app.ts b/examples/shadertoy-server/src/mcp-app.ts
index 546de5a..7cbb849 100644
--- a/examples/shadertoy-server/src/mcp-app.ts
+++ b/examples/shadertoy-server/src/mcp-app.ts
@@ -7,6 +7,7 @@ import {
applyHostStyleVariables,
applyDocumentTheme,
} from "@modelcontextprotocol/ext-apps";
+import { z } from "zod";
import "./global.css";
import "./mcp-app.css";
import ShaderToyLite, {
@@ -39,7 +40,8 @@ const log = {
// Get element references
const mainEl = document.querySelector(".main") as HTMLElement;
const canvas = document.getElementById("canvas") as HTMLCanvasElement;
-const codePreview = document.getElementById("code-preview") as HTMLPreElement;
+// TODO: code-preview streaming toggle was removed when ontoolinputpartial was
+// dropped during app-tool refactor โ element still exists in HTML, restore if needed
const fullscreenBtn = document.getElementById(
"fullscreen-btn",
) as HTMLButtonElement;
@@ -100,6 +102,39 @@ fullscreenBtn.addEventListener("click", toggleFullscreen);
// ShaderToyLite instance
let shaderToy: ShaderToyLiteInstance | null = null;
+// Track current shader sources
+let currentShaderSources: ShaderInput = {
+ fragmentShader: "",
+};
+
+// Track compilation status
+interface CompilationStatus {
+ success: boolean;
+ errors: string[];
+ timestamp: number;
+}
+let lastCompilationStatus: CompilationStatus = {
+ success: true,
+ errors: [],
+ timestamp: Date.now(),
+};
+
+// Intercept console.error to capture shader compilation errors
+const originalConsoleError = console.error.bind(console);
+const capturedErrors: string[] = [];
+console.error = (...args: unknown[]) => {
+ originalConsoleError(...args);
+ // Capture shader compilation errors
+ const message = args.map((arg) => String(arg)).join(" ");
+ if (
+ message.includes("Shader compilation failed") ||
+ message.includes("Program initialization failed") ||
+ message.includes("Failed to compile")
+ ) {
+ capturedErrors.push(message);
+ }
+};
+
// Create app instance
const app = new App({ name: "ShaderToy Renderer", version: "1.0.0" });
@@ -111,35 +146,18 @@ app.onteardown = async () => {
return {};
};
-app.ontoolinputpartial = (params) => {
- // Show code preview, hide canvas
- codePreview.classList.add("visible");
- canvas.classList.add("hidden");
- const code = params.arguments?.fragmentShader;
- codePreview.textContent = typeof code === "string" ? code : "";
- codePreview.scrollTop = codePreview.scrollHeight;
-};
-
-app.ontoolinput = (params) => {
- log.info("Received shader input");
-
- // Hide code preview, show canvas
- codePreview.classList.remove("visible");
- canvas.classList.remove("hidden");
-
- if (!isShaderInput(params.arguments)) {
- log.error("Invalid tool input");
- return;
- }
-
- const { fragmentShader, common, bufferA, bufferB, bufferC, bufferD } =
- params.arguments;
+// Helper function to compile shader and update status
+function compileAndUpdateStatus(input: ShaderInput): void {
+ // Clear captured errors before compilation
+ capturedErrors.length = 0;
// Initialize ShaderToyLite if needed
if (!shaderToy) {
shaderToy = new ShaderToyLite("canvas");
}
+ const { fragmentShader, common, bufferA, bufferB, bufferC, bufferD } = input;
+
// Set common code (shared across all shaders)
shaderToy.setCommon(common || "");
@@ -167,6 +185,45 @@ app.ontoolinput = (params) => {
});
shaderToy.play();
+
+ // Update compilation status
+ const hasErrors = capturedErrors.length > 0;
+ lastCompilationStatus = {
+ success: !hasErrors,
+ errors: [...capturedErrors],
+ timestamp: Date.now(),
+ };
+
+ // Store current sources
+ currentShaderSources = { ...input };
+
+ // Send compilation status to model context if there are errors
+ if (hasErrors) {
+ app
+ .updateModelContext({
+ content: [
+ {
+ type: "text",
+ text: `Shader compilation failed:\n${capturedErrors.join("\n")}`,
+ },
+ ],
+ structuredContent: {
+ compilationStatus: lastCompilationStatus,
+ },
+ })
+ .catch((err) => log.error("Failed to update model context:", err));
+ }
+}
+
+app.ontoolinput = (params) => {
+ log.info("Received shader input");
+
+ if (!isShaderInput(params.arguments)) {
+ log.error("Invalid tool input");
+ return;
+ }
+
+ compileAndUpdateStatus(params.arguments);
log.info("Setup complete");
};
@@ -174,6 +231,97 @@ app.onerror = log.error;
app.onhostcontextchanged = handleHostContextChanged;
+// Register tool: set-shader-source
+app.registerTool(
+ "set-shader-source",
+ {
+ title: "Set Shader Source",
+ description:
+ "Update the shader source code. Compiles and runs the new shader immediately.",
+ inputSchema: z.object({
+ fragmentShader: z
+ .string()
+ .describe("The main fragment shader source code (mainImage function)"),
+ common: z
+ .string()
+ .optional()
+ .describe("Common code shared across all shaders"),
+ bufferA: z
+ .string()
+ .optional()
+ .describe("Buffer A shader source (for multi-pass rendering)"),
+ bufferB: z
+ .string()
+ .optional()
+ .describe("Buffer B shader source (for multi-pass rendering)"),
+ bufferC: z
+ .string()
+ .optional()
+ .describe("Buffer C shader source (for multi-pass rendering)"),
+ bufferD: z
+ .string()
+ .optional()
+ .describe("Buffer D shader source (for multi-pass rendering)"),
+ }),
+ },
+ async (args) => {
+ log.info("set-shader-source tool called");
+
+ compileAndUpdateStatus(args);
+
+ const result = lastCompilationStatus.success
+ ? "Shader compiled and running successfully."
+ : `Shader compilation failed:\n${lastCompilationStatus.errors.join("\n")}`;
+
+ return {
+ content: [{ type: "text" as const, text: result }],
+ structuredContent: {
+ success: lastCompilationStatus.success,
+ errors: lastCompilationStatus.errors,
+ timestamp: lastCompilationStatus.timestamp,
+ },
+ };
+ },
+);
+
+// Register tool: get-shader-info
+app.registerTool(
+ "get-shader-info",
+ {
+ title: "Get Shader Info",
+ description: "Get the current shader source code and compilation status.",
+ },
+ async () => {
+ log.info("get-shader-info tool called");
+
+ const hasShader = currentShaderSources.fragmentShader.length > 0;
+ const isPlaying = shaderToy?.isPlaying() ?? false;
+
+ let statusText = "";
+ if (!hasShader) {
+ statusText = "No shader loaded.";
+ } else if (lastCompilationStatus.success) {
+ statusText = `Shader is ${isPlaying ? "running" : "paused"}.`;
+ } else {
+ statusText = `Shader has compilation errors:\n${lastCompilationStatus.errors.join("\n")}`;
+ }
+
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: `${statusText}\n\nCurrent fragment shader:\n${currentShaderSources.fragmentShader || "(none)"}`,
+ },
+ ],
+ structuredContent: {
+ sources: currentShaderSources,
+ compilationStatus: lastCompilationStatus,
+ isPlaying,
+ },
+ };
+ },
+);
+
// Pause/resume shader based on visibility
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
diff --git a/examples/shadertoy-server/tsconfig.json b/examples/shadertoy-server/tsconfig.json
index 535267b..b028ff5 100644
--- a/examples/shadertoy-server/tsconfig.json
+++ b/examples/shadertoy-server/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/shadertoy-server/tsconfig.server.json b/examples/shadertoy-server/tsconfig.server.json
index 05ddd8e..27eb28f 100644
--- a/examples/shadertoy-server/tsconfig.server.json
+++ b/examples/shadertoy-server/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/examples/sheet-music-server/README.md b/examples/sheet-music-server/README.md
index 6c8b770..556b726 100644
--- a/examples/sheet-music-server/README.md
+++ b/examples/sheet-music-server/README.md
@@ -4,8 +4,8 @@ A demo MCP App that renders [ABC notation](https://en.wikipedia.org/wiki/ABC_not
diff --git a/examples/sheet-music-server/main.ts b/examples/sheet-music-server/main.ts
index 8b143a7..d7bf3a0 100644
--- a/examples/sheet-music-server/main.ts
+++ b/examples/sheet-music-server/main.ts
@@ -4,10 +4,10 @@
* Or: node dist/index.js [--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 { createMcpExpressApp } from "@modelcontextprotocol/express";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
@@ -27,7 +27,7 @@ export async function startStreamableHTTPServer(
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
diff --git a/examples/sheet-music-server/package.json b/examples/sheet-music-server/package.json
index 54ea185..85ca6bb 100644
--- a/examples/sheet-music-server/package.json
+++ b/examples/sheet-music-server/package.json
@@ -1,11 +1,11 @@
{
"name": "@modelcontextprotocol/server-sheet-music",
- "version": "1.1.2",
+ "version": "2.0.0",
"type": "module",
"description": "MCP App Server for rendering and playing sheet music from ABC notation",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/sheet-music-server"
},
"license": "MIT",
@@ -17,17 +17,21 @@
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"",
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
"serve": "bun --watch main.ts",
+ "serve:stdio": "bun main.ts --stdio",
"start": "cross-env NODE_ENV=development npm run build && npm run serve",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
"dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"",
"prepublishOnly": "npm run build"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"abcjs": "^6.4.4",
"cors": "^2.8.5",
"express": "^5.1.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
diff --git a/examples/sheet-music-server/server.ts b/examples/sheet-music-server/server.ts
index cfd319b..be7214a 100644
--- a/examples/sheet-music-server/server.ts
+++ b/examples/sheet-music-server/server.ts
@@ -1,8 +1,8 @@
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type {
- CallToolResult,
- ReadResourceResult,
-} from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
diff --git a/examples/sheet-music-server/tsconfig.json b/examples/sheet-music-server/tsconfig.json
index 535267b..b028ff5 100644
--- a/examples/sheet-music-server/tsconfig.json
+++ b/examples/sheet-music-server/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/sheet-music-server/tsconfig.server.json b/examples/sheet-music-server/tsconfig.server.json
index 05ddd8e..27eb28f 100644
--- a/examples/sheet-music-server/tsconfig.server.json
+++ b/examples/sheet-music-server/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/examples/system-monitor-server/README.md b/examples/system-monitor-server/README.md
index 6b79794..a754e60 100644
--- a/examples/system-monitor-server/README.md
+++ b/examples/system-monitor-server/README.md
@@ -4,9 +4,9 @@ A demo MCP App that displays real-time OS metrics with a stacked area chart for
diff --git a/examples/system-monitor-server/main.ts b/examples/system-monitor-server/main.ts
index 365a560..c55c799 100644
--- a/examples/system-monitor-server/main.ts
+++ b/examples/system-monitor-server/main.ts
@@ -4,10 +4,10 @@
* Or: node dist/index.js [--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 { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
+import { createMcpExpressApp } from "@modelcontextprotocol/express";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
@@ -27,7 +27,7 @@ export async function startStreamableHTTPServer(
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
diff --git a/examples/system-monitor-server/package.json b/examples/system-monitor-server/package.json
index 0806b0b..2dc3ef1 100644
--- a/examples/system-monitor-server/package.json
+++ b/examples/system-monitor-server/package.json
@@ -1,11 +1,11 @@
{
"name": "@modelcontextprotocol/server-system-monitor",
- "version": "1.1.2",
+ "version": "2.0.0",
"type": "module",
"description": "System monitor MCP App Server with real-time stats",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/system-monitor-server"
},
"license": "MIT",
@@ -17,22 +17,24 @@
"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 server.ts --stdio",
+ "serve:stdio": "bun main.ts --stdio",
"start": "npm run start:http",
"start:http": "cross-env NODE_ENV=development npm run build && npm run serve:http",
- "start:stdio": "cross-env NODE_ENV=development npm run build && npm run serve:stdio",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
"dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve:http\"",
"prepublishOnly": "npm run build",
"serve": "bun --watch main.ts"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"chart.js": "^4.4.0",
"cors": "^2.8.5",
"express": "^5.1.0",
- "systeminformation": "^5.27.11",
- "zod": "^4.1.13"
+ "systeminformation": "^5.31.6",
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
diff --git a/examples/system-monitor-server/server.ts b/examples/system-monitor-server/server.ts
index 8fac5f2..a17bcf6 100644
--- a/examples/system-monitor-server/server.ts
+++ b/examples/system-monitor-server/server.ts
@@ -3,11 +3,11 @@ import {
registerAppResource,
registerAppTool,
} from "@modelcontextprotocol/ext-apps/server";
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type {
- CallToolResult,
- ReadResourceResult,
-} from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -135,8 +135,8 @@ export function createServer(): McpServer {
title: "Get System Info",
description:
"Returns system information, including hostname, platform, CPU info, and memory.",
- inputSchema: {},
- outputSchema: SystemInfoSchema.shape,
+ inputSchema: z.object({}),
+ outputSchema: SystemInfoSchema,
_meta: { ui: { resourceUri } },
},
(): CallToolResult => {
@@ -156,8 +156,8 @@ export function createServer(): McpServer {
title: "Poll System Stats",
description:
"Returns dynamic system metrics for polling: per-core CPU timing, memory usage, and uptime. App-only.",
- inputSchema: {},
- outputSchema: PollStatsSchema.shape,
+ inputSchema: z.object({}),
+ outputSchema: PollStatsSchema,
_meta: { ui: { visibility: ["app"] } },
},
async (): Promise => {
diff --git a/examples/system-monitor-server/tsconfig.json b/examples/system-monitor-server/tsconfig.json
index 535267b..b028ff5 100644
--- a/examples/system-monitor-server/tsconfig.json
+++ b/examples/system-monitor-server/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/system-monitor-server/tsconfig.server.json b/examples/system-monitor-server/tsconfig.server.json
index 05ddd8e..27eb28f 100644
--- a/examples/system-monitor-server/tsconfig.server.json
+++ b/examples/system-monitor-server/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/examples/threejs-server/main.ts b/examples/threejs-server/main.ts
index 13a2084..ef16e26 100644
--- a/examples/threejs-server/main.ts
+++ b/examples/threejs-server/main.ts
@@ -4,10 +4,10 @@
* Or: node dist/index.js [--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 { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
+import { createMcpExpressApp } from "@modelcontextprotocol/express";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
@@ -27,7 +27,7 @@ export async function startStreamableHTTPServer(
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
diff --git a/examples/threejs-server/package.json b/examples/threejs-server/package.json
index d984170..68864d9 100644
--- a/examples/threejs-server/package.json
+++ b/examples/threejs-server/package.json
@@ -1,11 +1,11 @@
{
"name": "@modelcontextprotocol/server-threejs",
- "version": "1.1.2",
+ "version": "2.0.0",
"type": "module",
"description": "Three.js 3D visualization MCP App Server",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/threejs-server"
},
"license": "MIT",
@@ -17,23 +17,26 @@
"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 server.ts --stdio",
+ "serve:stdio": "bun main.ts --stdio",
"start": "npm run start:http",
"start:http": "cross-env NODE_ENV=development npm run build && npm run serve:http",
- "start:stdio": "cross-env NODE_ENV=development npm run build && npm run serve:stdio",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
"dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve:http\"",
"prepublishOnly": "npm run build",
"serve": "bun --watch main.ts"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"three": "^0.181.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
diff --git a/examples/threejs-server/server.ts b/examples/threejs-server/server.ts
index ae320df..0a13c19 100644
--- a/examples/threejs-server/server.ts
+++ b/examples/threejs-server/server.ts
@@ -8,8 +8,10 @@ import {
registerAppResource,
registerAppTool,
} from "@modelcontextprotocol/ext-apps/server";
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
@@ -161,7 +163,7 @@ export function createServer(): McpServer {
title: "Show Three.js Scene",
description:
"Render an interactive 3D scene with custom Three.js code. Supports transparent backgrounds (alpha: true) for seamless host UI integration. Available globals: THREE, OrbitControls, EffectComposer, RenderPass, UnrealBloomPass, canvas, width, height.",
- inputSchema: {
+ inputSchema: z.object({
code: z
.string()
.default(DEFAULT_THREEJS_CODE)
@@ -172,7 +174,7 @@ export function createServer(): McpServer {
.positive()
.default(400)
.describe("Height in pixels"),
- },
+ }),
outputSchema: z.object({
success: z.boolean(),
}),
@@ -192,7 +194,7 @@ export function createServer(): McpServer {
{
title: "Learn Three.js",
description: "Get documentation and examples for using the Three.js View",
- inputSchema: {},
+ inputSchema: z.object({}),
},
async () => {
return {
diff --git a/examples/threejs-server/src/global.css b/examples/threejs-server/src/global.css
index c969ccc..1542cec 100644
--- a/examples/threejs-server/src/global.css
+++ b/examples/threejs-server/src/global.css
@@ -92,6 +92,16 @@ body {
opacity: 0.7;
}
+/* Always show button on touch devices */
+.threejs-container.touch-device .fullscreen-btn.available {
+ opacity: 0.7;
+}
+
+/* In fullscreen, canvas goes edge-to-edge; only the button respects safe area */
+.threejs-container.fullscreen {
+ padding: 0 !important;
+}
+
/* Fullscreen mode: swap icons and remove border radius */
.threejs-container.fullscreen .fullscreen-btn .expand-icon {
display: none;
diff --git a/examples/threejs-server/src/mcp-app-wrapper.tsx b/examples/threejs-server/src/mcp-app-wrapper.tsx
index f9dd263..44fee0c 100644
--- a/examples/threejs-server/src/mcp-app-wrapper.tsx
+++ b/examples/threejs-server/src/mcp-app-wrapper.tsx
@@ -5,10 +5,11 @@
* props to the actual view component.
*/
import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps";
-import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";
-import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
-import { StrictMode, useState, useEffect } from "react";
+import { useApp } from "@modelcontextprotocol/ext-apps/react";
+import type { CallToolResult } from "@modelcontextprotocol/client";
+import { StrictMode, useState, useCallback, useEffect, useRef } from "react";
import { createRoot } from "react-dom/client";
+import { z } from "zod";
import ThreeJSApp from "./threejs-app.tsx";
import "./global.css";
@@ -16,6 +17,20 @@ import "./global.css";
// Types
// =============================================================================
+/**
+ * Scene state tracked for view interaction tools.
+ */
+export interface SceneState {
+ /** Current Three.js code */
+ code: string | null;
+ /** Canvas height */
+ height: number;
+ /** Last error message if any */
+ error: string | null;
+ /** Whether the scene is currently rendering */
+ isRendering: boolean;
+}
+
/**
* Props passed to the view component.
* This interface can be reused for other views.
@@ -31,6 +46,105 @@ export interface ViewProps> {
toolResult: CallToolResult | null;
/** Host context (theme, dimensions, locale, etc.) */
hostContext: McpUiHostContext | null;
+ /** Call a tool on the MCP server */
+ callServerTool: App["callServerTool"];
+ /** Send a message to the host's chat */
+ sendMessage: App["sendMessage"];
+ /** Request the host to open a URL */
+ openLink: App["openLink"];
+ /** Send log messages to the host */
+ sendLog: App["sendLog"];
+ /** Callback to report scene errors */
+ onSceneError: (error: string | null) => void;
+ /** Callback to report scene is rendering */
+ onSceneRendering: (isRendering: boolean) => void;
+}
+
+// =============================================================================
+// Widget Interaction Tools
+// =============================================================================
+
+/**
+ * Registers widget interaction tools on the App instance.
+ * These tools allow the model to interact with the Three.js scene.
+ */
+function registerWidgetTools(
+ app: App,
+ sceneStateRef: React.RefObject,
+ setToolInputs: (args: Record) => void,
+): void {
+ // Tool: set-scene-source - Update the scene source/configuration
+ app.registerTool(
+ "set-scene-source",
+ {
+ title: "Set Scene Source",
+ description:
+ "Update the Three.js scene source code. The code will be executed in a sandboxed environment with access to THREE, OrbitControls, EffectComposer, RenderPass, UnrealBloomPass, canvas, width, and height.",
+ inputSchema: z.object({
+ code: z.string().describe("JavaScript code to render the 3D scene"),
+ height: z
+ .number()
+ .int()
+ .positive()
+ .optional()
+ .describe("Height in pixels (optional, defaults to current)"),
+ }),
+ outputSchema: z.object({
+ success: z.boolean(),
+ code: z.string(),
+ height: z.number(),
+ }),
+ },
+ async (args) => {
+ sceneStateRef.current.code = args.code;
+ if (args.height !== undefined) {
+ sceneStateRef.current.height = args.height;
+ }
+ sceneStateRef.current.error = null;
+ setToolInputs({ code: args.code, height: sceneStateRef.current.height });
+
+ const result = {
+ success: true,
+ code: args.code,
+ height: sceneStateRef.current.height,
+ };
+
+ return {
+ content: [{ type: "text" as const, text: JSON.stringify(result) }],
+ structuredContent: result,
+ };
+ },
+ );
+
+ // Tool: get-scene-info - Get current scene state and any errors
+ app.registerTool(
+ "get-scene-info",
+ {
+ title: "Get Scene Info",
+ description:
+ "Get the current Three.js scene state including source code, dimensions, rendering status, and any errors.",
+ outputSchema: z.object({
+ code: z.string().nullable(),
+ height: z.number(),
+ error: z.string().nullable(),
+ isRendering: z.boolean(),
+ }),
+ },
+ async () => {
+ const state = sceneStateRef.current;
+ const result = {
+ code: state.code,
+ height: state.height,
+ error: state.error,
+ isRendering: state.isRendering,
+ };
+
+ return {
+ content: [{ type: "text" as const, text: JSON.stringify(result) }],
+ structuredContent: result,
+ };
+ },
+ );
}
// =============================================================================
@@ -48,14 +162,38 @@ function McpAppWrapper() {
const [toolResult, setToolResult] = useState(null);
const [hostContext, setHostContext] = useState(null);
+ // Scene state for widget interaction tools
+ const sceneStateRef = useRef({
+ code: null,
+ height: 400,
+ error: null,
+ isRendering: false,
+ });
+
+ // Reference to app for tools to access updateModelContext
+ const appRef = useRef(null);
+
const { app, error } = useApp({
appInfo: { name: "Three.js View", version: "1.0.0" },
- capabilities: {},
+ capabilities: { tools: {} },
onAppCreated: (app) => {
+ appRef.current = app;
+
+ // Register widget interaction tools before connect()
+ registerWidgetTools(app, sceneStateRef, setToolInputs);
+
// Complete tool input (streaming finished)
app.ontoolinput = (params) => {
- setToolInputs(params.arguments as Record);
+ const args = params.arguments as Record;
+ setToolInputs(args);
setToolInputsPartial(null);
+ // Update scene state from tool input
+ if (typeof args.code === "string") {
+ sceneStateRef.current.code = args.code;
+ }
+ if (typeof args.height === "number") {
+ sceneStateRef.current.height = args.height;
+ }
};
// Partial tool input (streaming in progress)
app.ontoolinputpartial = (params) => {
@@ -66,14 +204,39 @@ function McpAppWrapper() {
setToolResult(params as CallToolResult);
};
// Host context changes (theme, dimensions, etc.)
+ // Note: we handle styles here instead of using useHostStyles,
+ // because useHostStyles overwrites onhostcontextchanged.
app.onhostcontextchanged = (params) => {
+ const root = document.documentElement;
+ if (params.theme) {
+ root.setAttribute("data-theme", params.theme);
+ root.style.colorScheme = params.theme;
+ }
+ if (params.styles?.variables) {
+ for (const [k, v] of Object.entries(params.styles.variables)) {
+ if (v !== undefined) root.style.setProperty(k, v as string);
+ }
+ }
setHostContext((prev) => ({ ...prev, ...params }));
};
},
});
- // Apply host styling (theme, CSS variables, fonts)
- useHostStyles(app);
+ // Apply initial host styles
+ useEffect(() => {
+ if (!app) return;
+ const ctx = app.getHostContext();
+ const root = document.documentElement;
+ if (ctx?.theme) {
+ root.setAttribute("data-theme", ctx.theme);
+ root.style.colorScheme = ctx.theme;
+ }
+ if (ctx?.styles?.variables) {
+ for (const [k, v] of Object.entries(ctx.styles.variables)) {
+ if (v !== undefined) root.style.setProperty(k, v as string);
+ }
+ }
+ }, [app]);
// Get initial host context after connection
useEffect(() => {
@@ -85,6 +248,53 @@ function McpAppWrapper() {
}
}, [app]);
+ // Memoized callbacks that forward to app methods
+ const callServerTool = useCallback(
+ (params, options) => app!.callServerTool(params, options),
+ [app],
+ );
+ const sendMessage = useCallback(
+ (params, options) => app!.sendMessage(params, options),
+ [app],
+ );
+ const openLink = useCallback(
+ (params, options) => app!.openLink(params, options),
+ [app],
+ );
+ const sendLog = useCallback(
+ (params) => app!.sendLog(params),
+ [app],
+ );
+
+ // Callback for scene to report errors
+ const onSceneError = useCallback((sceneError: string | null) => {
+ sceneStateRef.current.error = sceneError;
+
+ // Send errors to model context for awareness
+ if (sceneError && appRef.current) {
+ void appRef.current
+ .updateModelContext({
+ content: [
+ {
+ type: "text" as const,
+ text: `Three.js Scene Error: ${sceneError}`,
+ },
+ ],
+ structuredContent: {
+ type: "scene_error",
+ error: sceneError,
+ timestamp: new Date().toISOString(),
+ },
+ })
+ .catch(() => {});
+ }
+ }, []);
+
+ // Callback for scene to report rendering state
+ const onSceneRendering = useCallback((isRendering: boolean) => {
+ sceneStateRef.current.isRendering = isRendering;
+ }, []);
+
if (error) {
return Error: {error.message}
;
}
@@ -100,6 +310,12 @@ function McpAppWrapper() {
toolInputsPartial={toolInputsPartial}
toolResult={toolResult}
hostContext={hostContext}
+ callServerTool={callServerTool}
+ sendMessage={sendMessage}
+ openLink={openLink}
+ sendLog={sendLog}
+ onSceneError={onSceneError}
+ onSceneRendering={onSceneRendering}
/>
);
}
diff --git a/examples/threejs-server/src/threejs-app.tsx b/examples/threejs-server/src/threejs-app.tsx
index 2359316..966bd64 100644
--- a/examples/threejs-server/src/threejs-app.tsx
+++ b/examples/threejs-server/src/threejs-app.tsx
@@ -85,6 +85,7 @@ function LoadingShimmer({ height, code }: { height: number; code?: string }) {
display: "flex",
flexDirection: "column",
overflow: "hidden",
+ touchAction: "none",
background:
"linear-gradient(135deg, var(--color-background-secondary, light-dark(#f0f0f5, #2a2a3c)) 0%, var(--color-background-tertiary, light-dark(#e5e5ed, #1e1e2e)) 100%)",
}}
@@ -199,6 +200,12 @@ export default function ThreeJSApp({
toolInputs,
toolInputsPartial,
hostContext,
+ callServerTool: _callServerTool,
+ sendMessage: _sendMessage,
+ openLink: _openLink,
+ sendLog: _sendLog,
+ onSceneError,
+ onSceneRendering,
}: ThreeJSAppProps) {
const [error, setError] = useState(null);
const [currentDisplayMode, setCurrentDisplayMode] = useState<
@@ -226,6 +233,8 @@ export default function ThreeJSApp({
const canFullscreen =
hostContext?.availableDisplayModes?.includes("fullscreen") ?? false;
const isFullscreen = currentDisplayMode === "fullscreen";
+ const dims = hostContext?.containerDimensions;
+ const hostHeight = dims && "height" in dims ? dims.height : 0;
// Sync display mode from host context
useEffect(() => {
@@ -272,25 +281,60 @@ export default function ThreeJSApp({
return () => observer.disconnect();
}, []);
+ // Track container width for resize handling
+ const [containerWidth, setContainerWidth] = useState(0);
+
+ useEffect(() => {
+ if (!containerRef.current) return;
+ const observer = new ResizeObserver((entries) => {
+ const w = Math.round(entries[0].contentRect.width);
+ if (w > 0) setContainerWidth(w);
+ });
+ observer.observe(containerRef.current);
+ return () => observer.disconnect();
+ }, []);
+
useEffect(() => {
if (!code || !canvasRef.current || !containerRef.current) return;
+ // Prevent touch events from propagating to the parent scroll view.
+ // Three.js OrbitControls uses pointer events, which don't suppress native
+ // scroll gesture recognition on touch devices.
+ const canvas = canvasRef.current;
+ const preventDefault = (e: TouchEvent) => e.preventDefault();
+ canvas.addEventListener("touchstart", preventDefault, { passive: false });
+ canvas.addEventListener("touchmove", preventDefault, { passive: false });
+
// Cleanup previous animation
animControllerRef.current?.cleanup();
animControllerRef.current = createAnimationController();
setError(null);
- const width = containerRef.current.offsetWidth || 800;
+ onSceneError(null);
+ onSceneRendering(true);
+
+ const w = containerWidth || containerRef.current.offsetWidth || 800;
+ const h = isFullscreen && hostHeight > 0 ? hostHeight : height;
executeThreeCode(
code,
canvasRef.current,
- width,
- height,
+ w,
+ h,
animControllerRef.current.visibilityAwareRAF,
- ).catch((e) => setError(e instanceof Error ? e.message : "Unknown error"));
+ ).catch((e) => {
+ const msg = e instanceof Error ? e.message : "Unknown error";
+ setError(msg);
+ onSceneError(msg);
+ onSceneRendering(false);
+ });
- return () => animControllerRef.current?.cleanup();
- }, [code, height]);
+ return () => {
+ canvas.removeEventListener("touchstart", preventDefault);
+ canvas.removeEventListener("touchmove", preventDefault);
+ animControllerRef.current?.cleanup();
+ onSceneRendering(false);
+ };
+ }, [code, height, containerWidth, isFullscreen, hostHeight]);
if (isStreaming || !code) {
return (
@@ -303,7 +347,7 @@ export default function ThreeJSApp({
return (
0 ? hostHeight : height,
+ borderRadius: isFullscreen ? 0 : "var(--border-radius-lg, 8px)",
display: "block",
+ touchAction: "none",
}}
/>
{error && Error: {error}
}
@@ -321,6 +366,10 @@ export default function ThreeJSApp({
className={`fullscreen-btn${canFullscreen ? " available" : ""}`}
title={isFullscreen ? "Exit fullscreen" : "Toggle fullscreen"}
onClick={toggleFullscreen}
+ style={{
+ top: 10 + (safeAreaInsets?.top ?? 0),
+ right: 10 + (safeAreaInsets?.right ?? 0),
+ }}
>
{
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
diff --git a/examples/transcript-server/package.json b/examples/transcript-server/package.json
index e72c109..46a8f98 100644
--- a/examples/transcript-server/package.json
+++ b/examples/transcript-server/package.json
@@ -1,11 +1,11 @@
{
"name": "@modelcontextprotocol/server-transcript",
- "version": "1.1.2",
+ "version": "2.0.0",
"type": "module",
"description": "MCP App Server for live speech transcription",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/transcript-server"
},
"license": "MIT",
@@ -17,16 +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": "bun --watch main.ts",
+ "serve:stdio": "bun main.ts --stdio",
"start": "cross-env NODE_ENV=development npm run build && npm run serve",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
"dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"",
"prepublishOnly": "npm run build"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
diff --git a/examples/transcript-server/server.ts b/examples/transcript-server/server.ts
index 035c8b6..863eb29 100644
--- a/examples/transcript-server/server.ts
+++ b/examples/transcript-server/server.ts
@@ -1,21 +1,21 @@
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type {
- CallToolResult,
- ReadResourceResult,
-} from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
import {
registerAppTool,
registerAppResource,
RESOURCE_MIME_TYPE,
- RESOURCE_URI_META_KEY,
} from "@modelcontextprotocol/ext-apps/server";
+import { z } from "zod";
// Works both from source (server.ts) and compiled (dist/server.js)
const DIST_DIR = import.meta.filename.endsWith(".ts")
? path.join(import.meta.dirname, "dist")
: import.meta.dirname;
-const RESOURCE_URI = "ui://transcript/mcp-app.html";
+const resourceUri = "ui://transcript/mcp-app.html";
/**
* Creates a new MCP server instance with tools and resources registered.
@@ -34,8 +34,8 @@ export function createServer(): McpServer {
title: "Transcribe Speech",
description:
"Opens a live speech transcription interface using the Web Speech API.",
- inputSchema: {},
- _meta: { [RESOURCE_URI_META_KEY]: RESOURCE_URI },
+ inputSchema: z.object({}),
+ _meta: { ui: { resourceUri } },
},
async (): Promise => {
return {
@@ -55,8 +55,8 @@ export function createServer(): McpServer {
// Register the UI resource
registerAppResource(
server,
- RESOURCE_URI,
- RESOURCE_URI,
+ resourceUri,
+ resourceUri,
{ mimeType: RESOURCE_MIME_TYPE, description: "Transcript UI" },
async (): Promise => {
const html = await fs.readFile(
@@ -67,7 +67,7 @@ export function createServer(): McpServer {
return {
contents: [
{
- uri: RESOURCE_URI,
+ uri: resourceUri,
mimeType: RESOURCE_MIME_TYPE,
text: html,
_meta: {
diff --git a/examples/transcript-server/tsconfig.json b/examples/transcript-server/tsconfig.json
index 535267b..00e4801 100644
--- a/examples/transcript-server/tsconfig.json
+++ b/examples/transcript-server/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client", "dom-speech-recognition"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/transcript-server/tsconfig.server.json b/examples/transcript-server/tsconfig.server.json
index 05ddd8e..27eb28f 100644
--- a/examples/transcript-server/tsconfig.server.json
+++ b/examples/transcript-server/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/examples/video-resource-server/main.ts b/examples/video-resource-server/main.ts
index 082fa5a..1983373 100644
--- a/examples/video-resource-server/main.ts
+++ b/examples/video-resource-server/main.ts
@@ -4,10 +4,10 @@
* Or: node dist/index.js [--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 { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
+import { createMcpExpressApp } from "@modelcontextprotocol/express";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
@@ -27,7 +27,7 @@ export async function startStreamableHTTPServer(
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
diff --git a/examples/video-resource-server/mcp-app.html b/examples/video-resource-server/mcp-app.html
index 437c020..93c8cbb 100644
--- a/examples/video-resource-server/mcp-app.html
+++ b/examples/video-resource-server/mcp-app.html
@@ -21,6 +21,15 @@
+
+
+
Available Videos
+
+ Select a video...
+
+ Load Video
diff --git a/examples/video-resource-server/package.json b/examples/video-resource-server/package.json
index cf64132..4a7a61f 100644
--- a/examples/video-resource-server/package.json
+++ b/examples/video-resource-server/package.json
@@ -1,11 +1,11 @@
{
"name": "@modelcontextprotocol/server-video-resource",
- "version": "1.1.2",
+ "version": "2.0.0",
"type": "module",
"description": "MCP App Server demonstrating video resources served as base64 blobs",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/video-resource-server"
},
"license": "MIT",
@@ -17,16 +17,21 @@
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"",
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
"serve": "bun --watch main.ts",
+ "serve:stdio": "bun main.ts --stdio",
"start": "cross-env NODE_ENV=development npm run build && npm run serve",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
"dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"",
"prepublishOnly": "npm run build"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
diff --git a/examples/video-resource-server/server.ts b/examples/video-resource-server/server.ts
index 2397665..8ae6240 100644
--- a/examples/video-resource-server/server.ts
+++ b/examples/video-resource-server/server.ts
@@ -12,11 +12,9 @@ import {
import {
McpServer,
ResourceTemplate,
-} from "@modelcontextprotocol/sdk/server/mcp.js";
-import type {
- CallToolResult,
- ReadResourceResult,
-} from "@modelcontextprotocol/sdk/types.js";
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
@@ -69,7 +67,16 @@ export function createServer(): McpServer {
server.registerResource(
"video",
- new ResourceTemplate("videos://{id}", { list: undefined }),
+ new ResourceTemplate("videos://{id}", {
+ list: async () => ({
+ resources: Object.entries(VIDEO_LIBRARY).map(([id, video]) => ({
+ uri: `videos://${id}`,
+ name: `video-${id}`,
+ description: `Video: ${video.description}`,
+ mimeType: "video/mp4",
+ })),
+ }),
+ }),
{
description: "Video served via MCP resource (base64 blob)",
mimeType: "video/mp4",
@@ -126,14 +133,14 @@ Available videos:
${Object.entries(VIDEO_LIBRARY)
.map(([id, v]) => `- ${id}: ${v.description}`)
.join("\n")}`,
- inputSchema: {
+ inputSchema: z.object({
videoId: z
.enum(Object.keys(VIDEO_LIBRARY) as [string, ...string[]])
.default("bunny-1mb")
.describe(
`Video ID to play. Available: ${Object.keys(VIDEO_LIBRARY).join(", ")}`,
),
- },
+ }),
outputSchema: z.object({
videoUri: z.string(),
description: z.string(),
diff --git a/examples/video-resource-server/src/global.css b/examples/video-resource-server/src/global.css
index b53cf5e..d94a599 100644
--- a/examples/video-resource-server/src/global.css
+++ b/examples/video-resource-server/src/global.css
@@ -1,10 +1,94 @@
+: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: system-ui, -apple-system, sans-serif;
- font-size: 1rem;
+ 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);
margin: 0;
padding: 0;
}
+
+h1 {
+ font-size: var(--font-heading-3xl-size);
+ line-height: var(--font-heading-3xl-line-height);
+}
+h2 {
+ font-size: var(--font-heading-2xl-size);
+ line-height: var(--font-heading-2xl-line-height);
+}
+h3 {
+ font-size: var(--font-heading-xl-size);
+ line-height: var(--font-heading-xl-line-height);
+}
+h4 {
+ font-size: var(--font-heading-lg-size);
+ line-height: var(--font-heading-lg-line-height);
+}
+h5 {
+ font-size: var(--font-heading-md-size);
+ line-height: var(--font-heading-md-line-height);
+}
+h6 {
+ font-size: var(--font-heading-sm-size);
+ line-height: var(--font-heading-sm-line-height);
+}
+
+code, pre, kbd {
+ font-family: var(--font-mono);
+ font-size: 1em;
+}
+
+b, strong {
+ font-weight: var(--font-weight-bold);
+}
diff --git a/examples/video-resource-server/src/mcp-app.css b/examples/video-resource-server/src/mcp-app.css
index 3312553..3413974 100644
--- a/examples/video-resource-server/src/mcp-app.css
+++ b/examples/video-resource-server/src/mcp-app.css
@@ -8,16 +8,16 @@
flex-direction: column;
align-items: center;
justify-content: center;
- padding: 2rem;
- gap: 1rem;
- color: #6b7280;
+ padding: var(--spacing-lg);
+ gap: var(--spacing-md);
+ color: var(--color-text-info);
}
.spinner {
width: 48px;
height: 48px;
- border: 4px solid #e5e7eb;
- border-top-color: #3b82f6;
+ border: 4px solid color-mix(in srgb, var(--color-text-primary) 20%, transparent);
+ border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@@ -27,13 +27,13 @@
}
.error {
- padding: 1rem;
+ padding: var(--spacing-md);
color: #dc2626;
}
.error-title {
- font-weight: 600;
- margin: 0 0 0.5rem 0;
+ font-weight: var(--font-weight-bold);
+ margin: 0 0 var(--spacing-sm) 0;
}
.error p {
@@ -43,12 +43,75 @@
.player video {
width: 100%;
max-height: 480px;
- border-radius: 8px;
+ border-radius: var(--border-radius-md);
background-color: #000;
}
.video-info {
- margin-top: 0.5rem;
+ margin-top: var(--spacing-sm);
font-size: 0.875rem;
- color: #6b7280;
+ color: var(--color-text-info);
+}
+
+.picker-container {
+ padding: var(--spacing-md);
+}
+
+.picker-container h3 {
+ margin: 0 0 var(--spacing-md) 0;
+ font-size: var(--font-heading-lg-size);
+ font-weight: var(--font-weight-bold);
+}
+
+.video-picker {
+ width: 100%;
+ padding: var(--spacing-sm);
+ font-size: var(--font-text-md-size);
+ border: var(--border-width-regular) solid color-mix(in srgb, var(--color-text-primary) 20%, transparent);
+ border-radius: var(--border-radius-md);
+ margin-bottom: var(--spacing-md);
+ background-color: var(--color-background-primary);
+ color: var(--color-text-primary);
+ font-family: var(--font-sans);
+}
+
+@media (prefers-color-scheme: dark) {
+ .video-picker option {
+ background-color: var(--color-background-info);
+ color: var(--color-text-primary);
+ }
+}
+
+.load-btn,
+.change-btn {
+ padding: var(--spacing-sm) var(--spacing-md);
+ font-size: var(--font-text-md-size);
+ font-weight: var(--font-weight-bold);
+ color: var(--color-text-on-accent);
+ background-color: var(--color-accent);
+ border: none;
+ border-radius: var(--border-radius-md);
+ cursor: pointer;
+ transition: background-color 0.2s;
+}
+
+.load-btn:hover:not(:disabled),
+.change-btn:hover {
+ background-color: color-mix(in srgb, var(--color-accent) 85%, var(--color-background-inverse));
+}
+
+.load-btn:focus-visible,
+.change-btn:focus-visible {
+ outline: calc(var(--border-width-regular) * 2) solid var(--color-ring-primary);
+ outline-offset: var(--border-width-regular);
+}
+
+.load-btn:disabled {
+ background-color: color-mix(in srgb, var(--color-text-primary) 40%, transparent);
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
+.change-btn {
+ margin-top: var(--spacing-md);
}
diff --git a/examples/video-resource-server/src/mcp-app.ts b/examples/video-resource-server/src/mcp-app.ts
index bc7688a..2a3f8f9 100644
--- a/examples/video-resource-server/src/mcp-app.ts
+++ b/examples/video-resource-server/src/mcp-app.ts
@@ -5,8 +5,7 @@
* The video is served as a base64 blob and converted to a data URI for playback.
*/
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
-import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
-import { ReadResourceResultSchema } from "@modelcontextprotocol/sdk/types.js";
+import type { CallToolResult } from "@modelcontextprotocol/client";
import "./global.css";
import "./mcp-app.css";
@@ -19,9 +18,19 @@ const loadingEl = document.getElementById("loading")!;
const loadingTextEl = document.getElementById("loading-text")!;
const errorEl = document.getElementById("error")!;
const errorMessageEl = document.getElementById("error-message")!;
+const pickerContainerEl = document.getElementById("video-picker-container")!;
+const videoPickerEl = document.getElementById(
+ "video-picker",
+) as HTMLSelectElement;
+const loadVideoBtn = document.getElementById(
+ "load-video-btn",
+) as HTMLButtonElement;
const playerEl = document.getElementById("player")!;
const videoEl = document.getElementById("video") as HTMLVideoElement;
const videoInfoEl = document.getElementById("video-info")!;
+const changeVideoBtn = document.getElementById(
+ "change-video-btn",
+) as HTMLButtonElement;
// =============================================================================
// UI State Helpers
@@ -41,6 +50,7 @@ function showLoading(text: string) {
loadingTextEl.textContent = text;
loadingEl.style.display = "flex";
errorEl.style.display = "none";
+ pickerContainerEl.style.display = "none";
playerEl.style.display = "none";
}
@@ -48,15 +58,36 @@ function showError(message: string) {
errorMessageEl.textContent = message;
loadingEl.style.display = "none";
errorEl.style.display = "block";
+ pickerContainerEl.style.display = "none";
playerEl.style.display = "none";
}
-function showPlayer(dataUri: string, info: string) {
- videoEl.src = dataUri;
+function showPicker(keepPlayer = true) {
+ loadingEl.style.display = "none";
+ errorEl.style.display = "none";
+ pickerContainerEl.style.display = "block";
+ changeVideoBtn.style.display = "none";
+ if (!keepPlayer || !videoEl.src || videoEl.src === window.location.href) {
+ playerEl.style.display = "none";
+ }
+}
+
+let currentObjectUrl: string | null = null;
+
+function showPlayer(objectUrl: string, info: string) {
+ // Revoke previous Object URL to free memory
+ if (currentObjectUrl) {
+ URL.revokeObjectURL(currentObjectUrl);
+ }
+ currentObjectUrl = objectUrl;
+
+ videoEl.src = objectUrl;
videoInfoEl.textContent = info;
loadingEl.style.display = "none";
errorEl.style.display = "none";
+ pickerContainerEl.style.display = "none";
playerEl.style.display = "block";
+ changeVideoBtn.style.display = "inline-block";
}
// =============================================================================
@@ -65,8 +96,81 @@ function showPlayer(dataUri: string, info: string) {
const app = new App({ name: "Video Resource Player", version: "1.0.0" });
-// Handle tool result - Requests a resource via resources/read and converts the
-// base64 blob to a data URI for use in the browser.
+async function fetchAndPlayVideo(uri: string, label: string) {
+ console.info("Requesting resource:", uri);
+
+ const resourceResult = await app.readServerResource({ uri });
+
+ const content = resourceResult.contents[0];
+ if (!content || !("blob" in content)) {
+ throw new Error("Resource response did not contain blob data");
+ }
+
+ console.info("Resource received, blob size:", content.blob.length);
+
+ const mimeType = content.mimeType || "video/mp4";
+ const binary = Uint8Array.from(atob(content.blob), (c) => c.charCodeAt(0));
+ const objectUrl = URL.createObjectURL(new Blob([binary], { type: mimeType }));
+
+ showPlayer(objectUrl, label);
+}
+
+async function discoverVideos() {
+ // Don't interrupt playback with loading state - just show picker if video is loaded
+ const hasVideo = Boolean(videoEl.src && videoEl.src !== window.location.href);
+ if (!hasVideo) {
+ showLoading("Discovering available videos...");
+ }
+
+ try {
+ const resourceList = await app.listServerResources();
+
+ const videoResources = resourceList.resources.filter((r) =>
+ r.uri.startsWith("videos://"),
+ );
+
+ if (videoResources.length === 0) {
+ showError("No videos available. Server has no video resources.");
+ return;
+ }
+
+ videoPickerEl.innerHTML = 'Select a video... ';
+
+ videoResources.forEach((resource) => {
+ const option = document.createElement("option");
+ option.value = resource.uri;
+ option.text = resource.name || resource.description || resource.uri;
+ videoPickerEl.appendChild(option);
+ });
+
+ showPicker(hasVideo);
+ } catch (err) {
+ console.error("Error discovering videos:", err);
+ showError(err instanceof Error ? err.message : String(err));
+ }
+}
+
+async function loadSelectedVideo() {
+ const selectedUri = videoPickerEl.value;
+ if (!selectedUri) {
+ return;
+ }
+
+ showLoading("Loading video...");
+
+ try {
+ const selectedText =
+ videoPickerEl.options[videoPickerEl.selectedIndex].text;
+ await fetchAndPlayVideo(
+ selectedUri,
+ `${selectedText} (loaded via MCP resource)`,
+ );
+ } catch (err) {
+ console.error("Error loading video:", err);
+ showError(err instanceof Error ? err.message : String(err));
+ }
+}
+
app.ontoolresult = async (result) => {
console.info("Received tool result:", result);
@@ -82,28 +186,10 @@ app.ontoolresult = async (result) => {
showLoading("Fetching video from MCP resource...");
try {
- console.info("Requesting resource:", videoUri);
-
- const resourceResult = await app.request(
- { method: "resources/read", params: { uri: videoUri } },
- ReadResourceResultSchema,
+ await fetchAndPlayVideo(
+ videoUri,
+ `Loaded via MCP resource (${description})`,
);
-
- const content = resourceResult.contents[0];
- if (!content || !("blob" in content)) {
- throw new Error("Resource response did not contain blob data");
- }
-
- console.info("Resource received, blob size:", content.blob.length);
-
- showLoading("Converting to data URI...");
-
- const mimeType = content.mimeType || "video/mp4";
- const dataUri = `data:${mimeType};base64,${content.blob}`;
-
- console.info("Data URI created, length:", dataUri.length);
-
- showPlayer(dataUri, `Loaded via MCP resource (${description})`);
} catch (err) {
console.error("Error fetching resource:", err);
showError(err instanceof Error ? err.message : String(err));
@@ -115,6 +201,16 @@ app.onerror = (err) => {
showError(err instanceof Error ? err.message : String(err));
};
+videoPickerEl.addEventListener("change", () => {
+ loadVideoBtn.disabled = !videoPickerEl.value;
+});
+
+loadVideoBtn.addEventListener("click", loadSelectedVideo);
+
+changeVideoBtn.addEventListener("click", () => {
+ discoverVideos();
+});
+
function handleHostContextChanged(ctx: McpUiHostContext) {
if (ctx.safeAreaInsets) {
mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
diff --git a/examples/video-resource-server/tsconfig.json b/examples/video-resource-server/tsconfig.json
index 535267b..b028ff5 100644
--- a/examples/video-resource-server/tsconfig.json
+++ b/examples/video-resource-server/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/video-resource-server/tsconfig.server.json b/examples/video-resource-server/tsconfig.server.json
index 05ddd8e..27eb28f 100644
--- a/examples/video-resource-server/tsconfig.server.json
+++ b/examples/video-resource-server/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/examples/wiki-explorer-server/README.md b/examples/wiki-explorer-server/README.md
index 5bf4241..cc91b23 100644
--- a/examples/wiki-explorer-server/README.md
+++ b/examples/wiki-explorer-server/README.md
@@ -4,9 +4,9 @@ Visualizes Wikipedia link graphs using a force-directed layout. Explore how Wiki
diff --git a/examples/wiki-explorer-server/main.ts b/examples/wiki-explorer-server/main.ts
index cc7b3a2..bfeeffb 100644
--- a/examples/wiki-explorer-server/main.ts
+++ b/examples/wiki-explorer-server/main.ts
@@ -4,10 +4,10 @@
* Or: node dist/index.js [--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 { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
+import { createMcpExpressApp } from "@modelcontextprotocol/express";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
@@ -27,7 +27,7 @@ export async function startStreamableHTTPServer(
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
diff --git a/examples/wiki-explorer-server/package.json b/examples/wiki-explorer-server/package.json
index 4eedb14..52304cf 100644
--- a/examples/wiki-explorer-server/package.json
+++ b/examples/wiki-explorer-server/package.json
@@ -1,11 +1,11 @@
{
"name": "@modelcontextprotocol/server-wiki-explorer",
- "version": "1.1.2",
+ "version": "2.0.0",
"type": "module",
"description": "Wikipedia link explorer MCP App Server with graph visualization",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/wiki-explorer-server"
},
"license": "MIT",
@@ -17,21 +17,24 @@
"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 server.ts --stdio",
+ "serve:stdio": "bun main.ts --stdio",
"start": "npm run start:http",
"start:http": "cross-env NODE_ENV=development npm run build && npm run serve:http",
- "start:stdio": "cross-env NODE_ENV=development npm run build && npm run serve:stdio",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
"dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve:http\"",
"prepublishOnly": "npm run build",
"serve": "bun --watch main.ts"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@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",
"cheerio": "^1.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
diff --git a/examples/wiki-explorer-server/server.ts b/examples/wiki-explorer-server/server.ts
index fe2e89b..8bc4eb7 100644
--- a/examples/wiki-explorer-server/server.ts
+++ b/examples/wiki-explorer-server/server.ts
@@ -3,11 +3,11 @@ import {
registerAppResource,
registerAppTool,
} from "@modelcontextprotocol/ext-apps/server";
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type {
- CallToolResult,
- ReadResourceResult,
-} from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import * as cheerio from "cheerio";
import fs from "node:fs/promises";
import path from "node:path";
@@ -86,7 +86,7 @@ export function createServer(): McpServer {
{
title: "Get First-Degree Links",
description:
- "Returns all Wikipedia pages that the given page links to directly.",
+ "Returns all Wikipedia pages that the given page links to directly. The widget is interactive and exposes tools for exploring the graph (expanding nodes to see their links), searching for articles, and querying visible nodes.",
inputSchema: z.object({
url: z
.string()
diff --git a/examples/wiki-explorer-server/src/mcp-app.css b/examples/wiki-explorer-server/src/mcp-app.css
index 8f6f024..ea13ac2 100644
--- a/examples/wiki-explorer-server/src/mcp-app.css
+++ b/examples/wiki-explorer-server/src/mcp-app.css
@@ -1,6 +1,7 @@
#graph {
width: 100%;
height: 100vh;
+ touch-action: none;
}
#popup {
diff --git a/examples/wiki-explorer-server/src/mcp-app.ts b/examples/wiki-explorer-server/src/mcp-app.ts
index 7d95cb8..9833d9c 100644
--- a/examples/wiki-explorer-server/src/mcp-app.ts
+++ b/examples/wiki-explorer-server/src/mcp-app.ts
@@ -2,7 +2,8 @@
* Wiki Explorer - Force-directed graph visualization of Wikipedia link networks
*/
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
-import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
+import type { CallToolResult } from "@modelcontextprotocol/client";
+import { z } from "zod";
import {
forceCenter,
forceCollide,
@@ -111,6 +112,18 @@ const graph = new ForceGraph(container)
})
.graphData(graphData);
+// Prevent touch events from propagating to the parent scroll view.
+// force-graph uses pointer events, which don't suppress native scroll gesture
+// recognition on touch devices.
+const graphCanvas = container.querySelector("canvas");
+if (graphCanvas) {
+ for (const eventName of ["touchstart", "touchmove"] as const) {
+ graphCanvas.addEventListener(eventName, (e) => e.preventDefault(), {
+ passive: false,
+ });
+ }
+}
+
// Handle window resize
function handleResize() {
const { width, height } = container.getBoundingClientRect();
@@ -385,6 +398,346 @@ function handleHostContextChanged(ctx: McpUiHostContext) {
app.onhostcontextchanged = handleHostContextChanged;
+// =============================================================================
+// Widget Interaction Tools
+// =============================================================================
+
+// Tool: Search for a Wikipedia article and navigate to it
+app.registerTool(
+ "search-article",
+ {
+ title: "Search Article",
+ description:
+ "Search for a Wikipedia article and add it to the graph as the new starting point",
+ inputSchema: z.object({
+ query: z.string().describe("Search query for Wikipedia article"),
+ }),
+ },
+ async (args) => {
+ const { query } = args as { query: string };
+
+ // Construct Wikipedia search URL that redirects to the article
+ const searchUrl = `https://en.wikipedia.org/wiki/Special:Search?go=Go&search=${encodeURIComponent(query)}`;
+
+ // Use the server tool to fetch the article
+ const result = await app.callServerTool({
+ name: "get-first-degree-links",
+ arguments: { url: searchUrl },
+ });
+
+ const response = result.structuredContent as unknown as ToolResponse;
+ if (response && response.page) {
+ // Clear existing graph and start fresh with this article
+ graphData.nodes = [];
+ graphData.links = [];
+ initialUrl = response.page.url;
+ addNode(response.page.url, response.page.title, "default", {
+ x: 0,
+ y: 0,
+ });
+ graph.warmupTicks(100);
+ handleToolResultData(result);
+ graph.centerAt(0, 0, 500);
+
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: `Navigated to article: ${response.page.title}`,
+ },
+ ],
+ structuredContent: {
+ success: true,
+ article: response.page,
+ linksFound: response.links?.length ?? 0,
+ },
+ };
+ }
+
+ return {
+ content: [
+ { type: "text" as const, text: `Could not find article for: ${query}` },
+ ],
+ structuredContent: {
+ success: false,
+ error: "Article not found",
+ },
+ };
+ },
+);
+
+// Tool: Get information about the currently displayed article
+app.registerTool(
+ "get-current-article",
+ {
+ title: "Get Current Article",
+ description:
+ "Get information about the currently selected or initial article in the graph",
+ },
+ async () => {
+ const currentUrl = selectedNodeUrl || initialUrl;
+
+ if (!currentUrl) {
+ return {
+ content: [
+ { type: "text" as const, text: "No article is currently selected" },
+ ],
+ structuredContent: {
+ hasSelection: false,
+ article: null,
+ },
+ };
+ }
+
+ const node = graphData.nodes.find((n) => n.url === currentUrl);
+
+ if (!node) {
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: "Selected article not found in graph",
+ },
+ ],
+ structuredContent: {
+ hasSelection: false,
+ article: null,
+ },
+ };
+ }
+
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: `Current article: ${node.title}\nURL: ${node.url}\nState: ${node.state}`,
+ },
+ ],
+ structuredContent: {
+ hasSelection: true,
+ article: {
+ url: node.url,
+ title: node.title,
+ state: node.state,
+ isExpanded: node.state === "expanded",
+ hasError: node.state === "error",
+ errorMessage: node.errorMessage,
+ },
+ },
+ };
+ },
+);
+
+// Tool: Highlight a specific node in the graph
+app.registerTool(
+ "highlight-node",
+ {
+ title: "Highlight Node",
+ description:
+ "Highlight and center on a specific node in the graph by title or URL",
+ inputSchema: z.object({
+ identifier: z
+ .string()
+ .describe("The title or URL of the node to highlight"),
+ }),
+ },
+ async (args) => {
+ const { identifier } = args as { identifier: string };
+ const lowerIdentifier = identifier.toLowerCase();
+
+ // Find node by title (case-insensitive partial match) or exact URL
+ const node = graphData.nodes.find(
+ (n) =>
+ n.url === identifier || n.title.toLowerCase().includes(lowerIdentifier),
+ );
+
+ if (!node) {
+ return {
+ content: [
+ { type: "text" as const, text: `Node not found: ${identifier}` },
+ ],
+ structuredContent: {
+ success: false,
+ error: "Node not found in graph",
+ availableNodes: graphData.nodes.map((n) => n.title),
+ },
+ };
+ }
+
+ // Center on the node and select it
+ selectedNodeUrl = node.url;
+ if (node.x !== undefined && node.y !== undefined) {
+ graph.centerAt(node.x, node.y, 500);
+ graph.zoom(2, 500);
+ }
+
+ return {
+ content: [
+ { type: "text" as const, text: `Highlighted node: ${node.title}` },
+ ],
+ structuredContent: {
+ success: true,
+ node: {
+ url: node.url,
+ title: node.title,
+ state: node.state,
+ },
+ },
+ };
+ },
+);
+
+// Tool: Expand a node to show its linked pages
+app.registerTool(
+ "expand-node",
+ {
+ title: "Expand Node",
+ description:
+ "Expand a node to fetch and display all Wikipedia pages it links to. This is the core way to explore the graph.",
+ inputSchema: z.object({
+ identifier: z.string().describe("The title or URL of the node to expand"),
+ }),
+ },
+ async (args) => {
+ const { identifier } = args as { identifier: string };
+ const lowerIdentifier = identifier.toLowerCase();
+
+ // Find node by title (case-insensitive partial match) or exact URL
+ const node = graphData.nodes.find(
+ (n) =>
+ n.url === identifier || n.title.toLowerCase().includes(lowerIdentifier),
+ );
+
+ if (!node) {
+ return {
+ content: [
+ { type: "text" as const, text: `Node not found: ${identifier}` },
+ ],
+ structuredContent: {
+ success: false,
+ error: "Node not found in graph",
+ availableNodes: graphData.nodes.map((n) => n.title),
+ },
+ };
+ }
+
+ if (node.state === "expanded") {
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: `Node "${node.title}" is already expanded`,
+ },
+ ],
+ structuredContent: {
+ success: true,
+ alreadyExpanded: true,
+ node: { url: node.url, title: node.title },
+ },
+ };
+ }
+
+ if (node.state === "error") {
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: `Node "${node.title}" has an error: ${node.errorMessage}`,
+ },
+ ],
+ structuredContent: {
+ success: false,
+ error: node.errorMessage,
+ },
+ };
+ }
+
+ try {
+ // Fetch the linked pages using the server tool
+ const result = await app.callServerTool({
+ name: "get-first-degree-links",
+ arguments: { url: node.url },
+ });
+
+ graph.warmupTicks(0);
+ handleToolResultData(result);
+
+ const response = result.structuredContent as unknown as ToolResponse;
+ const linksAdded = response?.links?.length ?? 0;
+
+ // Center on the expanded node
+ if (node.x !== undefined && node.y !== undefined) {
+ graph.centerAt(node.x, node.y, 500);
+ }
+
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: `Expanded "${node.title}" - found ${linksAdded} linked articles`,
+ },
+ ],
+ structuredContent: {
+ success: true,
+ node: { url: node.url, title: node.title },
+ linksAdded,
+ },
+ };
+ } catch (e) {
+ setNodeState(node.url, "error", "Request failed");
+ updateGraph();
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: `Failed to expand "${node.title}": ${e instanceof Error ? e.message : String(e)}`,
+ },
+ ],
+ structuredContent: {
+ success: false,
+ error: String(e),
+ },
+ };
+ }
+ },
+);
+
+// Tool: Get list of currently visible nodes in the graph
+app.registerTool(
+ "get-visible-nodes",
+ {
+ title: "Get Visible Nodes",
+ description: "Get a list of all nodes currently visible in the graph",
+ },
+ async () => {
+ const nodes = graphData.nodes.map((n) => ({
+ url: n.url,
+ title: n.title,
+ state: n.state,
+ isExpanded: n.state === "expanded",
+ hasError: n.state === "error",
+ }));
+
+ const expandedCount = nodes.filter((n) => n.isExpanded).length;
+ const errorCount = nodes.filter((n) => n.hasError).length;
+
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: `Graph contains ${nodes.length} nodes:\n${nodes.map((n) => `- ${n.title} (${n.state})`).join("\n")}`,
+ },
+ ],
+ structuredContent: {
+ totalNodes: nodes.length,
+ expandedNodes: expandedCount,
+ errorNodes: errorCount,
+ nodes,
+ },
+ };
+ },
+);
+
// Connect to host
app.connect().then(() => {
const ctx = app.getHostContext();
diff --git a/examples/wiki-explorer-server/tsconfig.json b/examples/wiki-explorer-server/tsconfig.json
index 535267b..b028ff5 100644
--- a/examples/wiki-explorer-server/tsconfig.json
+++ b/examples/wiki-explorer-server/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/wiki-explorer-server/tsconfig.server.json b/examples/wiki-explorer-server/tsconfig.server.json
index 05ddd8e..27eb28f 100644
--- a/examples/wiki-explorer-server/tsconfig.server.json
+++ b/examples/wiki-explorer-server/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/package-lock.json b/package-lock.json
index 0dcf024..0189ca2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,25 +1,31 @@
{
"name": "@modelcontextprotocol/ext-apps",
- "version": "1.1.2",
+ "version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@modelcontextprotocol/ext-apps",
- "version": "1.1.2",
- "hasInstallScript": true,
+ "version": "2.0.0",
"license": "MIT",
"workspaces": [
"examples/*"
],
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0"
+ },
"devDependencies": {
"@boneskull/typedoc-plugin-mermaid": "^0.2.0",
- "@modelcontextprotocol/sdk": "1.25.2",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/core": "2.0.0",
+ "@modelcontextprotocol/sdk": "1.30.0",
+ "@modelcontextprotocol/server": "2.0.0",
"@playwright/test": "1.57.0",
"@types/bun": "^1.3.2",
"@types/node": "20.19.27",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
+ "bun": "^1.2.21",
"caniuse-lite": "1.0.30001763",
"cheerio": "1.1.2",
"concurrently": "^9.2.1",
@@ -28,6 +34,7 @@
"electron-to-chromium": "1.5.267",
"esbuild": "^0.25.12",
"express": "^5.1.0",
+ "ext-apps-v1": "npm:@modelcontextprotocol/ext-apps@1.7.5",
"husky": "^9.1.7",
"nodemon": "^3.1.0",
"playwright": "1.57.0",
@@ -35,40 +42,29 @@
"prettier": "^3.6.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
- "sharp": "^0.34.5",
+ "sharp": "^0.35.3",
"ts-to-zod": "^5.1.0",
"tsx": "^4.21.0",
"typedoc": "^0.28.14",
"typedoc-github-theme": "^0.4.0",
"typescript": "^5.9.3",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
- "optionalDependencies": {
- "@oven/bun-darwin-aarch64": "^1.2.21",
- "@oven/bun-darwin-x64": "^1.2.21",
- "@oven/bun-darwin-x64-baseline": "^1.2.21",
- "@oven/bun-linux-aarch64": "^1.2.21",
- "@oven/bun-linux-aarch64-musl": "^1.2.21",
- "@oven/bun-linux-x64": "^1.2.21",
- "@oven/bun-linux-x64-baseline": "^1.2.21",
- "@oven/bun-linux-x64-musl": "^1.2.21",
- "@oven/bun-linux-x64-musl-baseline": "^1.2.21",
- "@oven/bun-windows-x64": "^1.2.21",
- "@oven/bun-windows-x64-baseline": "^1.2.21",
- "@rollup/rollup-darwin-arm64": "^4.53.3",
- "@rollup/rollup-darwin-x64": "^4.53.3",
- "@rollup/rollup-linux-arm64-gnu": "^4.53.3",
- "@rollup/rollup-linux-x64-gnu": "^4.53.3",
- "@rollup/rollup-win32-arm64-msvc": "^4.53.3",
- "@rollup/rollup-win32-x64-msvc": "^4.53.3"
+ "engines": {
+ "node": ">=20"
},
"peerDependencies": {
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "^2.0.0",
+ "@modelcontextprotocol/core": "^2.0.0",
+ "@modelcontextprotocol/server": "^2.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
- "zod": "^3.25.0 || ^4.0.0"
+ "zod": "^4.2.0"
},
"peerDependenciesMeta": {
+ "@modelcontextprotocol/server": {
+ "optional": true
+ },
"react": {
"optional": true
},
@@ -79,13 +75,13 @@
},
"examples/basic-host": {
"name": "@modelcontextprotocol/ext-apps-basic-host",
- "version": "1.1.2",
+ "version": "2.0.0",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
@@ -99,8 +95,7 @@
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"vite": "^6.0.0",
- "vite-plugin-singlefile": "^2.3.0",
- "vitest": "^3.2.4"
+ "vite-plugin-singlefile": "^2.3.0"
}
},
"examples/basic-host/node_modules/@types/node": {
@@ -122,15 +117,18 @@
},
"examples/basic-server-preact": {
"name": "@modelcontextprotocol/server-basic-preact",
- "version": "1.1.2",
+ "version": "2.0.0",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"preact": "^10.0.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"bin": {
"mcp-server-basic-preact": "dist/index.js"
@@ -166,16 +164,19 @@
},
"examples/basic-server-react": {
"name": "@modelcontextprotocol/server-basic-react",
- "version": "1.1.2",
+ "version": "2.0.0",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"bin": {
"mcp-server-basic-react": "dist/index.js"
@@ -213,15 +214,18 @@
},
"examples/basic-server-solid": {
"name": "@modelcontextprotocol/server-basic-solid",
- "version": "1.1.2",
+ "version": "2.0.0",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"solid-js": "1.9.10",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"bin": {
"mcp-server-basic-solid": "dist/index.js"
@@ -235,7 +239,7 @@
"typescript": "^5.9.3",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0",
- "vite-plugin-solid": "^2.0.0"
+ "vite-plugin-solid": "^2.11.12"
}
},
"examples/basic-server-solid/node_modules/@types/node": {
@@ -257,15 +261,18 @@
},
"examples/basic-server-svelte": {
"name": "@modelcontextprotocol/server-basic-svelte",
- "version": "1.1.2",
+ "version": "2.0.0",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"svelte": "^5.0.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"bin": {
"mcp-server-basic-svelte": "dist/index.js"
@@ -301,14 +308,17 @@
},
"examples/basic-server-vanillajs": {
"name": "@modelcontextprotocol/server-basic-vanillajs",
- "version": "1.1.2",
+ "version": "2.0.0",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"bin": {
"mcp-server-basic-vanillajs": "dist/index.js"
@@ -343,15 +353,18 @@
},
"examples/basic-server-vue": {
"name": "@modelcontextprotocol/server-basic-vue",
- "version": "1.1.2",
+ "version": "2.0.0",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"vue": "^3.5.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"bin": {
"mcp-server-basic-vue": "dist/index.js"
@@ -387,15 +400,17 @@
},
"examples/budget-allocator-server": {
"name": "@modelcontextprotocol/server-budget-allocator",
- "version": "1.1.2",
+ "version": "2.0.0",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"chart.js": "^4.4.0",
"cors": "^2.8.5",
"express": "^5.1.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"bin": {
"mcp-budget-allocator-server": "dist/index.js"
@@ -430,16 +445,18 @@
},
"examples/cohort-heatmap-server": {
"name": "@modelcontextprotocol/server-cohort-heatmap",
- "version": "1.1.2",
+ "version": "2.0.0",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"bin": {
"mcp-cohort-heatmap-server": "dist/index.js"
@@ -477,15 +494,17 @@
},
"examples/customer-segmentation-server": {
"name": "@modelcontextprotocol/server-customer-segmentation",
- "version": "1.1.2",
+ "version": "2.0.0",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"chart.js": "^4.4.0",
"cors": "^2.8.5",
"express": "^5.1.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"bin": {
"mcp-customer-segmentation-server": "dist/index.js"
@@ -520,12 +539,14 @@
},
"examples/debug-server": {
"name": "@modelcontextprotocol/server-debug",
- "version": "1.1.2",
+ "version": "2.0.0",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
- "zod": "^4.1.13"
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
+ "zod": "^4.2.0"
},
"bin": {
"mcp-server-debug": "dist/index.js"
@@ -561,15 +582,18 @@
"license": "MIT"
},
"examples/integration-server": {
- "version": "1.1.2",
+ "version": "2.0.0",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"bin": {
"mcp-integration-server": "dist/index.js"
@@ -604,16 +628,63 @@
"dev": true,
"license": "MIT"
},
- "examples/map-server": {
- "name": "@modelcontextprotocol/server-map",
- "version": "1.1.2",
+ "examples/lazy-auth-server": {
+ "name": "@modelcontextprotocol/server-lazy-auth",
+ "version": "2.0.0",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
- "zod": "^4.1.13"
+ "jose": "^6.0.0",
+ "zod": "^4.2.0"
+ },
+ "bin": {
+ "mcp-server-lazy-auth": "dist/index.js"
+ },
+ "devDependencies": {
+ "@types/cors": "^2.8.19",
+ "@types/express": "^5.0.0",
+ "@types/node": "22.10.0",
+ "concurrently": "^9.2.1",
+ "cross-env": "^10.1.0",
+ "typescript": "^5.9.3",
+ "vite": "^6.0.0",
+ "vite-plugin-singlefile": "^2.3.0"
+ }
+ },
+ "examples/lazy-auth-server/node_modules/@types/node": {
+ "version": "22.10.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz",
+ "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.20.0"
+ }
+ },
+ "examples/lazy-auth-server/node_modules/undici-types": {
+ "version": "6.20.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
+ "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "examples/map-server": {
+ "name": "@modelcontextprotocol/server-map",
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
+ "cors": "^2.8.5",
+ "express": "^5.1.0",
+ "zod": "^4.2.0"
},
"bin": {
"mcp-map-server": "dist/index.js"
@@ -648,15 +719,19 @@
},
"examples/pdf-server": {
"name": "@modelcontextprotocol/server-pdf",
- "version": "1.1.2",
+ "version": "2.0.0",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@cantoo/pdf-lib": "^2.6.5",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"pdfjs-dist": "^5.0.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"bin": {
"mcp-pdf-server": "dist/index.js"
@@ -691,20 +766,23 @@
},
"examples/qr-server": {
"name": "@modelcontextprotocol/server-qr",
- "version": "1.1.2",
+ "version": "2.0.0",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0"
+ "@modelcontextprotocol/ext-apps": "^2.0.0"
}
},
"examples/quickstart": {
"name": "@modelcontextprotocol/quickstart",
- "version": "1.1.2",
+ "version": "2.0.0",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
- "express": "^5.1.0"
+ "express": "^5.1.0",
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
@@ -730,25 +808,27 @@
},
"examples/say-server": {
"name": "@modelcontextprotocol/server-say",
- "version": "1.1.2",
+ "version": "2.0.0",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0"
+ "@modelcontextprotocol/ext-apps": "^2.0.0"
}
},
"examples/scenario-modeler-server": {
"name": "@modelcontextprotocol/server-scenario-modeler",
- "version": "1.1.2",
+ "version": "2.0.0",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"chart.js": "^4.4.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"bin": {
"mcp-scenario-modeler-server": "dist/index.js"
@@ -786,14 +866,16 @@
},
"examples/shadertoy-server": {
"name": "@modelcontextprotocol/server-shadertoy",
- "version": "1.1.2",
+ "version": "2.0.0",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"bin": {
"mcp-shadertoy-server": "dist/index.js"
@@ -828,15 +910,17 @@
},
"examples/sheet-music-server": {
"name": "@modelcontextprotocol/server-sheet-music",
- "version": "1.1.2",
+ "version": "2.0.0",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"abcjs": "^6.4.4",
"cors": "^2.8.5",
"express": "^5.1.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"bin": {
"mcp-sheet-music-server": "dist/index.js"
@@ -871,16 +955,18 @@
},
"examples/system-monitor-server": {
"name": "@modelcontextprotocol/server-system-monitor",
- "version": "1.1.2",
+ "version": "2.0.0",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"chart.js": "^4.4.0",
"cors": "^2.8.5",
"express": "^5.1.0",
- "systeminformation": "^5.27.11",
- "zod": "^4.1.13"
+ "systeminformation": "^5.31.6",
+ "zod": "^4.2.0"
},
"bin": {
"mcp-system-monitor-server": "dist/index.js"
@@ -915,17 +1001,20 @@
},
"examples/threejs-server": {
"name": "@modelcontextprotocol/server-threejs",
- "version": "1.1.2",
+ "version": "2.0.0",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"three": "^0.181.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"bin": {
"mcp-threejs-server": "dist/index.js"
@@ -964,14 +1053,16 @@
},
"examples/transcript-server": {
"name": "@modelcontextprotocol/server-transcript",
- "version": "1.1.2",
+ "version": "2.0.0",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"bin": {
"mcp-transcript-server": "dist/index.js"
@@ -1007,14 +1098,17 @@
},
"examples/video-resource-server": {
"name": "@modelcontextprotocol/server-video-resource",
- "version": "1.1.2",
+ "version": "2.0.0",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"bin": {
"mcp-video-resource-server": "dist/index.js"
@@ -1049,15 +1143,18 @@
},
"examples/wiki-explorer-server": {
"name": "@modelcontextprotocol/server-wiki-explorer",
- "version": "1.1.2",
+ "version": "2.0.0",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^1.0.0",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@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",
"cheerio": "^1.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"bin": {
"mcp-wiki-explorer-server": "dist/index.js"
@@ -1092,13 +1189,13 @@
"license": "MIT"
},
"node_modules/@babel/code-frame": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
- "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -1107,9 +1204,9 @@
}
},
"node_modules/@babel/compat-data": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz",
- "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1117,21 +1214,21 @@
}
},
"node_modules/@babel/core": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
- "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.28.6",
- "@babel/generator": "^7.28.6",
- "@babel/helper-compilation-targets": "^7.28.6",
- "@babel/helper-module-transforms": "^7.28.6",
- "@babel/helpers": "^7.28.6",
- "@babel/parser": "^7.28.6",
- "@babel/template": "^7.28.6",
- "@babel/traverse": "^7.28.6",
- "@babel/types": "^7.28.6",
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -1158,14 +1255,14 @@
}
},
"node_modules/@babel/generator": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz",
- "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
+ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.28.6",
- "@babel/types": "^7.28.6",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -1188,14 +1285,14 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
- "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/compat-data": "^7.28.6",
- "@babel/helper-validator-option": "^7.27.1",
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
@@ -1215,9 +1312,9 @@
}
},
"node_modules/@babel/helper-globals": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
- "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1225,29 +1322,29 @@
}
},
"node_modules/@babel/helper-module-imports": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
- "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/traverse": "^7.28.6",
- "@babel/types": "^7.28.6"
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
- "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-module-imports": "^7.28.6",
- "@babel/helper-validator-identifier": "^7.28.5",
- "@babel/traverse": "^7.28.6"
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -1267,27 +1364,27 @@
}
},
"node_modules/@babel/helper-string-parser": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
- "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
- "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-option": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
- "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1295,26 +1392,26 @@
}
},
"node_modules/@babel/helpers": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
- "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/template": "^7.28.6",
- "@babel/types": "^7.28.6"
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
- "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.28.6"
+ "@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -1408,33 +1505,33 @@
}
},
"node_modules/@babel/template": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
- "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.28.6",
- "@babel/parser": "^7.28.6",
- "@babel/types": "^7.28.6"
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz",
- "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.28.6",
- "@babel/generator": "^7.28.6",
- "@babel/helper-globals": "^7.28.0",
- "@babel/parser": "^7.28.6",
- "@babel/template": "^7.28.6",
- "@babel/types": "^7.28.6",
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7",
"debug": "^4.3.1"
},
"engines": {
@@ -1442,13 +1539,13 @@
}
},
"node_modules/@babel/types": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
- "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"license": "MIT",
"dependencies": {
- "@babel/helper-string-parser": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.28.5"
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -1473,6 +1570,21 @@
}
}
},
+ "node_modules/@cantoo/pdf-lib": {
+ "version": "2.6.5",
+ "resolved": "https://registry.npmjs.org/@cantoo/pdf-lib/-/pdf-lib-2.6.5.tgz",
+ "integrity": "sha512-3eMHEaqKHt/G/q+6QjT06A3lz0S/a8x3+myiSN7FNeL3uWcedO0lpfs6TWofa4C03Z1wz3tWeHoa4CsI7DrTSA==",
+ "license": "MIT",
+ "dependencies": {
+ "@pdf-lib/standard-fonts": "^1.0.0",
+ "@pdf-lib/upng": "^1.0.1",
+ "color": "^4.2.3",
+ "crypto-js": "^4.2.0",
+ "node-html-better-parser": ">=1.4.0",
+ "pako": "^1.0.11",
+ "tslib": ">=2"
+ }
+ },
"node_modules/@clack/core": {
"version": "1.0.0-alpha.4",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.0.0-alpha.4.tgz",
@@ -1504,9 +1616,9 @@
"license": "Apache-2.0"
},
"node_modules/@emnapi/runtime": {
- "version": "1.8.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
- "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
+ "version": "1.11.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
+ "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -1978,9 +2090,9 @@
}
},
"node_modules/@hono/node-server": {
- "version": "1.19.7",
- "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz",
- "integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==",
+ "version": "1.19.14",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
+ "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@@ -1990,9 +2102,9 @@
}
},
"node_modules/@img/colour": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
- "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2000,9 +2112,9 @@
}
},
"node_modules/@img/sharp-darwin-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
- "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
+ "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [
"arm64"
],
@@ -2013,19 +2125,19 @@
"darwin"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ "@img/sharp-libvips-darwin-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-darwin-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
- "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
+ "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [
"x64"
],
@@ -2036,19 +2148,39 @@
"darwin"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-darwin-x64": "1.2.4"
+ "@img/sharp-libvips-darwin-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
+ "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
- "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
+ "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [
"arm64"
],
@@ -2063,9 +2195,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
- "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
+ "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [
"x64"
],
@@ -2080,9 +2212,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
- "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
+ "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [
"arm"
],
@@ -2097,9 +2229,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
- "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
+ "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [
"arm64"
],
@@ -2114,9 +2246,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
- "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
+ "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"cpu": [
"ppc64"
],
@@ -2131,9 +2263,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
- "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
+ "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"cpu": [
"riscv64"
],
@@ -2148,9 +2280,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
- "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
+ "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [
"s390x"
],
@@ -2165,9 +2297,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
- "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
+ "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [
"x64"
],
@@ -2182,9 +2314,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
- "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
+ "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [
"arm64"
],
@@ -2199,9 +2331,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
- "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
+ "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [
"x64"
],
@@ -2216,9 +2348,9 @@
}
},
"node_modules/@img/sharp-linux-arm": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
- "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
+ "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [
"arm"
],
@@ -2229,19 +2361,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-arm": "1.2.4"
+ "@img/sharp-libvips-linux-arm": "1.3.2"
}
},
"node_modules/@img/sharp-linux-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
- "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
+ "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [
"arm64"
],
@@ -2252,19 +2384,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-arm64": "1.2.4"
+ "@img/sharp-libvips-linux-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-ppc64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
- "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
+ "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"cpu": [
"ppc64"
],
@@ -2275,19 +2407,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ "@img/sharp-libvips-linux-ppc64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-riscv64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
- "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
+ "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"cpu": [
"riscv64"
],
@@ -2298,19 +2430,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ "@img/sharp-libvips-linux-riscv64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-s390x": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
- "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
+ "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [
"s390x"
],
@@ -2321,19 +2453,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-s390x": "1.2.4"
+ "@img/sharp-libvips-linux-s390x": "1.3.2"
}
},
"node_modules/@img/sharp-linux-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
- "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
+ "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [
"x64"
],
@@ -2344,19 +2476,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-x64": "1.2.4"
+ "@img/sharp-libvips-linux-x64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
- "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
+ "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [
"arm64"
],
@@ -2367,19 +2499,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
- "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
+ "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [
"x64"
],
@@ -2390,39 +2522,56 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
}
},
"node_modules/@img/sharp-wasm32": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
- "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
- "cpu": [
- "wasm32"
- ],
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
+ "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
- "@emnapi/runtime": "^1.7.0"
+ "@emnapi/runtime": "^1.11.1"
},
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
+ "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
- "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
+ "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"cpu": [
"arm64"
],
@@ -2433,16 +2582,16 @@
"win32"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
- "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
+ "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [
"ia32"
],
@@ -2453,16 +2602,16 @@
"win32"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
- "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
+ "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [
"x64"
],
@@ -2473,7 +2622,7 @@
"win32"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
@@ -2530,64 +2679,93 @@
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
- "node_modules/@modelcontextprotocol/ext-apps": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.0.0.tgz",
- "integrity": "sha512-d5vGKBhWkRoa3xlKOynF8kd+sTSY2D3QSjTMCs46/ddOUOSn5e/E0SaShixFm7H7mNlj4uY0RuU0jAsPM/0qwA==",
- "hasInstallScript": true,
+ "node_modules/@modelcontextprotocol/client": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz",
+ "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==",
"license": "MIT",
- "workspaces": [
- "examples/*"
- ],
- "optionalDependencies": {
- "@oven/bun-darwin-aarch64": "^1.2.21",
- "@oven/bun-darwin-x64": "^1.2.21",
- "@oven/bun-darwin-x64-baseline": "^1.2.21",
- "@oven/bun-linux-aarch64": "^1.2.21",
- "@oven/bun-linux-aarch64-musl": "^1.2.21",
- "@oven/bun-linux-x64": "^1.2.21",
- "@oven/bun-linux-x64-baseline": "^1.2.21",
- "@oven/bun-linux-x64-musl": "^1.2.21",
- "@oven/bun-linux-x64-musl-baseline": "^1.2.21",
- "@oven/bun-windows-x64": "^1.2.21",
- "@oven/bun-windows-x64-baseline": "^1.2.21",
- "@rollup/rollup-darwin-arm64": "^4.53.3",
- "@rollup/rollup-darwin-x64": "^4.53.3",
- "@rollup/rollup-linux-arm64-gnu": "^4.53.3",
- "@rollup/rollup-linux-x64-gnu": "^4.53.3",
- "@rollup/rollup-win32-arm64-msvc": "^4.53.3",
- "@rollup/rollup-win32-x64-msvc": "^4.53.3"
+ "dependencies": {
+ "@modelcontextprotocol/core": "2.0.0",
+ "cross-spawn": "^7.0.5",
+ "eventsource": "^3.0.2",
+ "eventsource-parser": "^3.0.0",
+ "jose": "^6.1.3",
+ "pkce-challenge": "^5.0.0",
+ "zod": "^4.2.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@modelcontextprotocol/core": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz",
+ "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==",
+ "license": "MIT",
+ "dependencies": {
+ "zod": "^4.2.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@modelcontextprotocol/express": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/express/-/express-2.0.0.tgz",
+ "integrity": "sha512-Snlr8j9FR9LcVvEJPF7qJ7d5zTL4Bes2dk7RcacN9eSZ7OLohwxqhEvWu1+UxELyScgLbLLOdeVEGdwKI1iwVQ==",
+ "license": "MIT",
+ "dependencies": {
+ "cors": "^2.8.5"
+ },
+ "engines": {
+ "node": ">=20"
},
"peerDependencies": {
- "@modelcontextprotocol/sdk": "^1.24.0",
- "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
- "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
- "zod": "^3.25.0 || ^4.0.0"
- },
- "peerDependenciesMeta": {
- "react": {
- "optional": true
- },
- "react-dom": {
- "optional": true
- }
+ "@modelcontextprotocol/server": "^2.0.0",
+ "express": "^4.18.0 || ^5.0.0"
}
},
+ "node_modules/@modelcontextprotocol/ext-apps": {
+ "resolved": "",
+ "link": true
+ },
"node_modules/@modelcontextprotocol/ext-apps-basic-host": {
"resolved": "examples/basic-host",
"link": true
},
+ "node_modules/@modelcontextprotocol/node": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/node/-/node-2.0.0.tgz",
+ "integrity": "sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg==",
+ "license": "MIT",
+ "dependencies": {
+ "@hono/node-server": "^1.19.9"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@modelcontextprotocol/server": "^2.0.0",
+ "hono": "^4.11.4"
+ },
+ "peerDependenciesMeta": {
+ "hono": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@modelcontextprotocol/quickstart": {
"resolved": "examples/quickstart",
"link": true
},
"node_modules/@modelcontextprotocol/sdk": {
- "version": "1.25.2",
- "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz",
- "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==",
+ "version": "1.30.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
+ "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
- "@hono/node-server": "^1.19.7",
+ "@hono/node-server": "^1.19.9 || ^2.0.5",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
@@ -2595,14 +2773,15 @@
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
- "express": "^5.0.1",
- "express-rate-limit": "^7.5.0",
- "jose": "^6.1.1",
+ "express": "^5.2.1",
+ "express-rate-limit": "^8.2.1",
+ "hono": "^4.11.4",
+ "jose": "^6.1.3",
"json-schema-typed": "^8.0.2",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.25 || ^4.0",
- "zod-to-json-schema": "^3.25.0"
+ "zod-to-json-schema": "^3.25.1"
},
"engines": {
"node": ">=18"
@@ -2620,6 +2799,19 @@
}
}
},
+ "node_modules/@modelcontextprotocol/server": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz",
+ "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==",
+ "license": "MIT",
+ "dependencies": {
+ "@modelcontextprotocol/core": "2.0.0",
+ "zod": "^4.2.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@modelcontextprotocol/server-basic-preact": {
"resolved": "examples/basic-server-preact",
"link": true
@@ -2660,6 +2852,10 @@
"resolved": "examples/debug-server",
"link": true
},
+ "node_modules/@modelcontextprotocol/server-lazy-auth": {
+ "resolved": "examples/lazy-auth-server",
+ "link": true
+ },
"node_modules/@modelcontextprotocol/server-map": {
"resolved": "examples/map-server",
"link": true
@@ -2989,9 +3185,9 @@
}
},
"node_modules/@oclif/core/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
+ "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2999,13 +3195,13 @@
}
},
"node_modules/@oclif/core/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
- "brace-expansion": "^2.0.1"
+ "brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -3015,12 +3211,13 @@
}
},
"node_modules/@oven/bun-darwin-aarch64": {
- "version": "1.3.6",
- "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.6.tgz",
- "integrity": "sha512-27rypIapNkYboOSylkf1tD9UW9Ado2I+P1NBL46Qz29KmOjTL6WuJ7mHDC5O66CYxlOkF5r93NPDAC3lFHYBXw==",
+ "version": "1.3.10",
+ "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.10.tgz",
+ "integrity": "sha512-PXgg5gqcS/rHwa1hF0JdM1y5TiyejVrMHoBmWY/DjtfYZoFTXie1RCFOkoG0b5diOOmUcuYarMpH7CSNTqwj+w==",
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3028,12 +3225,13 @@
]
},
"node_modules/@oven/bun-darwin-x64": {
- "version": "1.3.6",
- "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.6.tgz",
- "integrity": "sha512-I82xGzPkBxzBKgbl8DsA0RfMQCWTWjNmLjIEkW1ECiv3qK02kHGQ5FGUr/29L/SuvnGsULW4tBTRNZiMzL37nA==",
+ "version": "1.3.10",
+ "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.10.tgz",
+ "integrity": "sha512-Nhssuh7GBpP5PiDSOl3+qnoIG7PJo+ec2oomDevnl9pRY6x6aD2gRt0JE+uf+A8Om2D6gjeHCxjEdrw5ZHE8mA==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3041,12 +3239,13 @@
]
},
"node_modules/@oven/bun-darwin-x64-baseline": {
- "version": "1.3.6",
- "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.6.tgz",
- "integrity": "sha512-nqtr+pTsHqusYpG2OZc6s+AmpWDB/FmBvstrK0y5zkti4OqnCuu7Ev2xNjS7uyb47NrAFF40pWqkpaio5XEd7w==",
+ "version": "1.3.10",
+ "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.10.tgz",
+ "integrity": "sha512-w1gaTlqU0IJCmJ1X+PGHkdNU1n8Gemx5YKkjhkJIguvFINXEBB5U1KG82QsT65Tk4KyNMfbLTlmy4giAvUoKfA==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3054,12 +3253,13 @@
]
},
"node_modules/@oven/bun-linux-aarch64": {
- "version": "1.3.6",
- "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.6.tgz",
- "integrity": "sha512-YaQEAYjBanoOOtpqk/c5GGcfZIyxIIkQ2m1TbHjedRmJNwxzWBhGinSARFkrRIc3F8pRIGAopXKvJ/2rjN1LzQ==",
+ "version": "1.3.10",
+ "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.10.tgz",
+ "integrity": "sha512-OUgPHfL6+PM2Q+tFZjcaycN3D7gdQdYlWnwMI31DXZKY1r4HINWk9aEz9t/rNaHg65edwNrt7dsv9TF7xK8xIA==",
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3067,12 +3267,13 @@
]
},
"node_modules/@oven/bun-linux-aarch64-musl": {
- "version": "1.3.6",
- "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.6.tgz",
- "integrity": "sha512-FR+iJt17rfFgYgpxL3M67AUwujOgjw52ZJzB9vElI5jQXNjTyOKf8eH4meSk4vjlYF3h/AjKYd6pmN0OIUlVKQ==",
+ "version": "1.3.10",
+ "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.10.tgz",
+ "integrity": "sha512-Ui5pAgM7JE9MzHokF0VglRMkbak3lTisY4Mf1AZutPACXWgKJC5aGrgnHBfkl7QS6fEeYb0juy1q4eRznRHOsw==",
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3080,12 +3281,13 @@
]
},
"node_modules/@oven/bun-linux-x64": {
- "version": "1.3.6",
- "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.6.tgz",
- "integrity": "sha512-egfngj0dfJ868cf30E7B+ye9KUWSebYxOG4l9YP5eWeMXCtenpenx0zdKtAn9qxJgEJym5AN6trtlk+J6x8Lig==",
+ "version": "1.3.10",
+ "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.10.tgz",
+ "integrity": "sha512-bzUgYj/PIZziB/ZesIP9HUyfvh6Vlf3od+TrbTTyVEuCSMKzDPQVW/yEbRp0tcHO3alwiEXwJDrWrHAguXlgiQ==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3093,12 +3295,13 @@
]
},
"node_modules/@oven/bun-linux-x64-baseline": {
- "version": "1.3.6",
- "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.6.tgz",
- "integrity": "sha512-jRmnX18ak8WzqLrex3siw0PoVKyIeI5AiCv4wJLgSs7VKfOqrPycfHIWfIX2jdn7ngqbHFPzI09VBKANZ4Pckg==",
+ "version": "1.3.10",
+ "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.10.tgz",
+ "integrity": "sha512-oqvMDYpX6dGJO03HgO5bXuccEsH3qbdO3MaAiAlO4CfkBPLUXz3N0DDElg5hz0L6ktdDVKbQVE5lfe+LAUISQg==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3106,12 +3309,13 @@
]
},
"node_modules/@oven/bun-linux-x64-musl": {
- "version": "1.3.6",
- "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.6.tgz",
- "integrity": "sha512-YeXcJ9K6vJAt1zSkeA21J6pTe7PgDMLTHKGI3nQBiMYnYf7Ob3K+b/ChSCznrJG7No5PCPiQPg4zTgA+BOTmSA==",
+ "version": "1.3.10",
+ "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.10.tgz",
+ "integrity": "sha512-poVXvOShekbexHq45b4MH/mRjQKwACAC8lHp3Tz/hEDuz0/20oncqScnmKwzhBPEpqJvydXficXfBYuSim8opw==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3119,25 +3323,41 @@
]
},
"node_modules/@oven/bun-linux-x64-musl-baseline": {
- "version": "1.3.6",
- "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.6.tgz",
- "integrity": "sha512-7FjVnxnRTp/AgWqSQRT/Vt9TYmvnZ+4M+d9QOKh/Lf++wIFXFGSeAgD6bV1X/yr2UPVmZDk+xdhr2XkU7l2v3w==",
+ "version": "1.3.10",
+ "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.10.tgz",
+ "integrity": "sha512-/hOZ6S1VsTX6vtbhWVL9aAnOrdpuO54mAGUWpTdMz7dFG5UBZ/VUEiK0pBkq9A1rlBk0GeD/6Y4NBFl8Ha7cRA==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
+ "node_modules/@oven/bun-windows-aarch64": {
+ "version": "1.3.10",
+ "resolved": "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.3.10.tgz",
+ "integrity": "sha512-GXbz2swvN2DLw2dXZFeedMxSJtI64xQ9xp9Eg7Hjejg6mS2E4dP1xoQ2yAo2aZPi/2OBPAVaGzppI2q20XumHA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/@oven/bun-windows-x64": {
- "version": "1.3.6",
- "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.6.tgz",
- "integrity": "sha512-Sr1KwUcbB0SEpnSPO22tNJppku2khjFluEst+mTGhxHzAGQTQncNeJxDnt3F15n+p9Q+mlcorxehd68n1siikQ==",
+ "version": "1.3.10",
+ "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.10.tgz",
+ "integrity": "sha512-qaS1In3yfC/Z/IGQriVmF8GWwKuNqiw7feTSJWaQhH5IbL6ENR+4wGNPniZSJFaM/SKUO0e/YCRdoVBvgU4C1g==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3145,18 +3365,37 @@
]
},
"node_modules/@oven/bun-windows-x64-baseline": {
- "version": "1.3.6",
- "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.6.tgz",
- "integrity": "sha512-PFUa7JL4lGoyyppeS4zqfuoXXih+gSE0XxhDMrCPVEUev0yhGNd/tbWBvcdpYnUth80owENoGjc8s5Knopv9wA==",
+ "version": "1.3.10",
+ "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.10.tgz",
+ "integrity": "sha512-gh3UAHbUdDUG6fhLc1Csa4IGdtghue6U8oAIXWnUqawp6lwb3gOCRvp25IUnLF5vUHtgfMxuEUYV7YA2WxVutw==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
+ "node_modules/@pdf-lib/standard-fonts": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
+ "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "^1.0.6"
+ }
+ },
+ "node_modules/@pdf-lib/upng": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
+ "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "^1.0.10"
+ }
+ },
"node_modules/@playwright/test": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
@@ -3258,9 +3497,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz",
- "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
+ "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
"cpu": [
"arm"
],
@@ -3272,9 +3511,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz",
- "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
+ "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
"cpu": [
"arm64"
],
@@ -3286,12 +3525,13 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz",
- "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
+ "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3299,12 +3539,13 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz",
- "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
+ "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3312,9 +3553,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz",
- "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
+ "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
"cpu": [
"arm64"
],
@@ -3326,9 +3567,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz",
- "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
+ "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
"cpu": [
"x64"
],
@@ -3340,9 +3581,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz",
- "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
+ "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
"cpu": [
"arm"
],
@@ -3354,9 +3595,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz",
- "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
+ "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
"cpu": [
"arm"
],
@@ -3368,12 +3609,13 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz",
- "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
+ "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3381,9 +3623,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz",
- "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
+ "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
"cpu": [
"arm64"
],
@@ -3395,9 +3637,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz",
- "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
+ "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
"cpu": [
"loong64"
],
@@ -3409,9 +3651,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz",
- "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
+ "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
"cpu": [
"loong64"
],
@@ -3423,9 +3665,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz",
- "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
+ "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
"cpu": [
"ppc64"
],
@@ -3437,9 +3679,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz",
- "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
+ "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
"cpu": [
"ppc64"
],
@@ -3451,9 +3693,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz",
- "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
+ "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
"cpu": [
"riscv64"
],
@@ -3465,9 +3707,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz",
- "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
+ "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
"cpu": [
"riscv64"
],
@@ -3479,9 +3721,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz",
- "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
+ "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
"cpu": [
"s390x"
],
@@ -3493,12 +3735,13 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz",
- "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
+ "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3506,9 +3749,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz",
- "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
+ "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
"cpu": [
"x64"
],
@@ -3520,9 +3763,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz",
- "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
+ "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
"cpu": [
"x64"
],
@@ -3534,9 +3777,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz",
- "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
+ "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
"cpu": [
"arm64"
],
@@ -3548,12 +3791,13 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz",
- "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
+ "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3561,9 +3805,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz",
- "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
+ "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
"cpu": [
"ia32"
],
@@ -3575,9 +3819,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz",
- "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
+ "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
"cpu": [
"x64"
],
@@ -3589,12 +3833,13 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz",
- "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
+ "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3650,10 +3895,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "license": "MIT"
+ },
"node_modules/@sveltejs/acorn-typescript": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
- "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz",
+ "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==",
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
@@ -3772,17 +4023,6 @@
"bun-types": "1.3.6"
}
},
- "node_modules/@types/chai": {
- "version": "5.2.3",
- "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
- "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/deep-eql": "*",
- "assertion-error": "^2.0.1"
- }
- },
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@@ -3803,13 +4043,6 @@
"@types/node": "*"
}
},
- "node_modules/@types/deep-eql": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
- "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@types/dom-speech-recognition": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/@types/dom-speech-recognition/-/dom-speech-recognition-0.0.7.tgz",
@@ -3953,6 +4186,12 @@
"meshoptimizer": "~0.22.0"
}
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT"
+ },
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -4015,131 +4254,6 @@
"vue": "^3.2.25"
}
},
- "node_modules/@vitest/expect": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
- "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/chai": "^5.2.2",
- "@vitest/spy": "3.2.4",
- "@vitest/utils": "3.2.4",
- "chai": "^5.2.0",
- "tinyrainbow": "^2.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/mocker": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
- "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/spy": "3.2.4",
- "estree-walker": "^3.0.3",
- "magic-string": "^0.30.17"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "msw": "^2.4.9",
- "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
- },
- "peerDependenciesMeta": {
- "msw": {
- "optional": true
- },
- "vite": {
- "optional": true
- }
- }
- },
- "node_modules/@vitest/mocker/node_modules/estree-walker": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
- "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "^1.0.0"
- }
- },
- "node_modules/@vitest/pretty-format": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
- "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "tinyrainbow": "^2.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/runner": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
- "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/utils": "3.2.4",
- "pathe": "^2.0.3",
- "strip-literal": "^3.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/snapshot": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
- "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/pretty-format": "3.2.4",
- "magic-string": "^0.30.17",
- "pathe": "^2.0.3"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/spy": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
- "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "tinyspy": "^4.0.3"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/utils": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
- "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/pretty-format": "3.2.4",
- "loupe": "^3.1.4",
- "tinyrainbow": "^2.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
"node_modules/@vue/compiler-core": {
"version": "3.5.27",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.27.tgz",
@@ -4293,9 +4407,9 @@
}
},
"node_modules/acorn": {
- "version": "8.15.0",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
- "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -4305,9 +4419,10 @@
}
},
"node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -4324,6 +4439,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
@@ -4414,24 +4530,14 @@
"license": "Python-2.0"
},
"node_modules/aria-query": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
- "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
+ "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
}
},
- "node_modules/assertion-error": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
- "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- }
- },
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -4449,9 +4555,9 @@
}
},
"node_modules/babel-plugin-jsx-dom-expressions": {
- "version": "0.40.3",
- "resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.40.3.tgz",
- "integrity": "sha512-5HOwwt0BYiv/zxl7j8Pf2bGL6rDXfV6nUhLs8ygBX+EFJXzBPHM/euj9j/6deMZ6wa52Wb2PBaAV5U/jKwIY1w==",
+ "version": "0.40.6",
+ "resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.40.6.tgz",
+ "integrity": "sha512-v3P1MW46Lm7VMpAkq0QfyzLWWkC8fh+0aE5Km4msIgDx5kjenHU0pF2s+4/NH8CQn/kla6+Hvws+2AF7bfV5qQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4489,17 +4595,17 @@
}
},
"node_modules/babel-preset-solid": {
- "version": "1.9.10",
- "resolved": "https://registry.npmjs.org/babel-preset-solid/-/babel-preset-solid-1.9.10.tgz",
- "integrity": "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ==",
+ "version": "1.9.12",
+ "resolved": "https://registry.npmjs.org/babel-preset-solid/-/babel-preset-solid-1.9.12.tgz",
+ "integrity": "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "babel-plugin-jsx-dom-expressions": "^0.40.3"
+ "babel-plugin-jsx-dom-expressions": "^0.40.6"
},
"peerDependencies": {
"@babel/core": "^7.0.0",
- "solid-js": "^1.9.10"
+ "solid-js": "^1.9.12"
},
"peerDependenciesMeta": {
"solid-js": {
@@ -4515,13 +4621,16 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
- "version": "2.9.18",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz",
- "integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==",
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz",
+ "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==",
"dev": true,
"license": "Apache-2.0",
"bin": {
- "baseline-browser-mapping": "dist/cli.js"
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
}
},
"node_modules/bezier-js": {
@@ -4549,20 +4658,20 @@
}
},
"node_modules/body-parser": {
- "version": "2.2.2",
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
- "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
+ "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
- "content-type": "^1.0.5",
+ "content-type": "^2.0.0",
"debug": "^4.4.3",
- "http-errors": "^2.0.0",
- "iconv-lite": "^0.7.0",
+ "http-errors": "^2.0.1",
+ "iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
- "qs": "^6.14.1",
- "raw-body": "^3.0.1",
- "type-is": "^2.0.1"
+ "qs": "^6.15.2",
+ "raw-body": "^3.0.2",
+ "type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
@@ -4572,6 +4681,19 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/body-parser/node_modules/content-type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/body-parser/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
@@ -4595,9 +4717,9 @@
"license": "ISC"
},
"node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "version": "1.1.16",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
+ "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4619,9 +4741,9 @@
}
},
"node_modules/browserslist": {
- "version": "4.28.1",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
- "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "version": "4.28.7",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
+ "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
"dev": true,
"funding": [
{
@@ -4639,11 +4761,11 @@
],
"license": "MIT",
"dependencies": {
- "baseline-browser-mapping": "^2.9.0",
- "caniuse-lite": "^1.0.30001759",
- "electron-to-chromium": "^1.5.263",
- "node-releases": "^2.0.27",
- "update-browserslist-db": "^1.2.0"
+ "baseline-browser-mapping": "^2.10.44",
+ "caniuse-lite": "^1.0.30001806",
+ "electron-to-chromium": "^1.5.393",
+ "node-releases": "^2.0.51",
+ "update-browserslist-db": "^1.2.3"
},
"bin": {
"browserslist": "cli.js"
@@ -4652,6 +4774,69 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/browserslist/node_modules/caniuse-lite": {
+ "version": "1.0.30001806",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
+ "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/browserslist/node_modules/electron-to-chromium": {
+ "version": "1.5.395",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz",
+ "integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/bun": {
+ "version": "1.3.10",
+ "resolved": "https://registry.npmjs.org/bun/-/bun-1.3.10.tgz",
+ "integrity": "sha512-S/CXaXXIyA4CMjdMkYQ4T2YMqnAn4s0ysD3mlsY4bUiOCqGlv28zck4Wd4H4kpvbekx15S9mUeLQ7Uxd0tYTLA==",
+ "cpu": [
+ "arm64",
+ "x64"
+ ],
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "os": [
+ "darwin",
+ "linux",
+ "win32"
+ ],
+ "bin": {
+ "bun": "bin/bun.exe",
+ "bunx": "bin/bunx.exe"
+ },
+ "optionalDependencies": {
+ "@oven/bun-darwin-aarch64": "1.3.10",
+ "@oven/bun-darwin-x64": "1.3.10",
+ "@oven/bun-darwin-x64-baseline": "1.3.10",
+ "@oven/bun-linux-aarch64": "1.3.10",
+ "@oven/bun-linux-aarch64-musl": "1.3.10",
+ "@oven/bun-linux-x64": "1.3.10",
+ "@oven/bun-linux-x64-baseline": "1.3.10",
+ "@oven/bun-linux-x64-musl": "1.3.10",
+ "@oven/bun-linux-x64-musl-baseline": "1.3.10",
+ "@oven/bun-windows-aarch64": "1.3.10",
+ "@oven/bun-windows-x64": "1.3.10",
+ "@oven/bun-windows-x64-baseline": "1.3.10"
+ }
+ },
"node_modules/bun-types": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.6.tgz",
@@ -4671,16 +4856,6 @@
"node": ">= 0.8"
}
},
- "node_modules/cac": {
- "version": "6.7.14",
- "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
- "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -4744,23 +4919,6 @@
"node": ">=12"
}
},
- "node_modules/chai": {
- "version": "5.3.3",
- "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
- "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "assertion-error": "^2.0.1",
- "check-error": "^2.1.1",
- "deep-eql": "^5.0.1",
- "loupe": "^3.1.0",
- "pathval": "^2.0.0"
- },
- "engines": {
- "node": ">=18"
- }
- },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -4803,16 +4961,6 @@
"pnpm": ">=8"
}
},
- "node_modules/check-error": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
- "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 16"
- }
- },
"node_modules/cheerio": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.2.tgz",
@@ -5006,11 +5154,23 @@
"node": ">=6"
}
},
+ "node_modules/color": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
+ "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1",
+ "color-string": "^1.9.0"
+ },
+ "engines": {
+ "node": ">=12.5.0"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -5023,9 +5183,18 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "dev": true,
"license": "MIT"
},
+ "node_modules/color-string": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
+ "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "^1.0.0",
+ "simple-swizzle": "^0.2.2"
+ }
+ },
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
@@ -5041,15 +5210,15 @@
"license": "MIT"
},
"node_modules/concurrently": {
- "version": "9.2.1",
- "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
- "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
+ "version": "9.2.4",
+ "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz",
+ "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
- "shell-quote": "1.8.3",
+ "shell-quote": "1.9.0",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
@@ -5157,6 +5326,12 @@
"node": ">= 8"
}
},
+ "node_modules/crypto-js": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
+ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
+ "license": "MIT"
+ },
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
@@ -5443,16 +5618,6 @@
}
}
},
- "node_modules/deep-eql": {
- "version": "5.0.2",
- "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
- "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -5483,9 +5648,9 @@
}
},
"node_modules/devalue": {
- "version": "5.6.2",
- "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
- "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
+ "version": "5.8.1",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
+ "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
"license": "MIT"
},
"node_modules/dom-serializer": {
@@ -5658,13 +5823,6 @@
"node": ">= 0.4"
}
},
- "node_modules/es-module-lexer": {
- "version": "1.7.0",
- "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
- "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -5755,12 +5913,20 @@
"license": "MIT"
},
"node_modules/esrap": {
- "version": "2.2.2",
- "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz",
- "integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==",
+ "version": "2.2.11",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.11.tgz",
+ "integrity": "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/types": "^8.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@typescript-eslint/types": {
+ "optional": true
+ }
}
},
"node_modules/estree-walker": {
@@ -5806,16 +5972,6 @@
"node": ">=18.0.0"
}
},
- "node_modules/expect-type": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
- "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=12.0.0"
- }
- },
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
@@ -5860,10 +6016,15 @@
}
},
"node_modules/express-rate-limit": {
- "version": "7.5.1",
- "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
- "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
+ "version": "8.7.0",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz",
+ "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==",
+ "dev": true,
"license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "ip-address": "^10.2.0"
+ },
"engines": {
"node": ">= 16"
},
@@ -5874,16 +6035,49 @@
"express": ">= 4.11"
}
},
+ "node_modules/ext-apps-v1": {
+ "name": "@modelcontextprotocol/ext-apps",
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.5.tgz",
+ "integrity": "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "examples/*"
+ ],
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@modelcontextprotocol/sdk": "^1.29.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "zod": "^3.25.0 || ^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
"license": "MIT"
},
"node_modules/fast-uri": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
- "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
+ "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
+ "dev": true,
"funding": [
{
"type": "github",
@@ -5914,9 +6108,9 @@
}
},
"node_modules/filelist/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
+ "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5924,9 +6118,9 @@
}
},
"node_modules/filelist/node_modules/minimatch": {
- "version": "5.1.6",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
- "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
+ "version": "5.1.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
+ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -6217,11 +6411,10 @@
}
},
"node_modules/hono": {
- "version": "4.11.6",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.6.tgz",
- "integrity": "sha512-ofIiiHyl34SV6AuhE3YT2mhO5HRWokce+eUYE82TsP6z0/H3JeJcjVWEMSIAiw2QkjDOEpES/lYsg8eEbsLtdw==",
+ "version": "4.12.31",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz",
+ "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -6230,7 +6423,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz",
"integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==",
- "dev": true,
"license": "MIT"
},
"node_modules/htmlparser2": {
@@ -6359,6 +6551,16 @@
"node": ">=12"
}
},
+ "node_modules/ip-address": {
+ "version": "10.7.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
+ "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -6368,6 +6570,12 @@
"node": ">= 0.10"
}
},
+ "node_modules/is-arrayish": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
+ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
+ "license": "MIT"
+ },
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -6544,12 +6752,14 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true,
"license": "MIT"
},
"node_modules/json-schema-typed": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
+ "dev": true,
"license": "BSD-2-Clause"
},
"node_modules/json5": {
@@ -6609,10 +6819,20 @@
}
},
"node_modules/linkify-it": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
- "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
+ "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
"dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/markdown-it"
+ }
+ ],
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
@@ -6699,9 +6919,9 @@
"license": "MIT"
},
"node_modules/lodash-es": {
- "version": "4.17.23",
- "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
- "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"dev": true,
"license": "MIT"
},
@@ -6797,13 +7017,6 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
- "node_modules/loupe": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
- "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -6831,15 +7044,25 @@
}
},
"node_modules/markdown-it": {
- "version": "14.1.0",
- "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
- "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
+ "version": "14.3.0",
+ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz",
+ "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==",
"dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/markdown-it"
+ }
+ ],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
- "entities": "^4.4.0",
- "linkify-it": "^5.0.0",
+ "entities": "^4.5.0",
+ "linkify-it": "^5.0.2",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
@@ -6961,9 +7184,9 @@
}
},
"node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -6980,9 +7203,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
- "version": "3.3.11",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"funding": [
{
"type": "github",
@@ -7006,6 +7229,15 @@
"node": ">= 0.6"
}
},
+ "node_modules/node-html-better-parser": {
+ "version": "1.5.8",
+ "resolved": "https://registry.npmjs.org/node-html-better-parser/-/node-html-better-parser-1.5.8.tgz",
+ "integrity": "sha512-t/wAKvaTSKco43X+yf9+76RiMt18MtMmzd4wc7rKj+fWav6DV4ajDEKdWlLzSE8USDF5zr/06uGj0Wr/dGAFtw==",
+ "license": "MIT",
+ "dependencies": {
+ "html-entities": "^2.3.2"
+ }
+ },
"node_modules/node-html-parser": {
"version": "6.1.13",
"resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz",
@@ -7018,11 +7250,14 @@
}
},
"node_modules/node-releases": {
- "version": "2.0.27",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
- "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
+ "version": "2.0.51",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
+ "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
},
"node_modules/nodemon": {
"version": "3.1.11",
@@ -7156,6 +7391,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@@ -7224,32 +7465,15 @@
}
},
"node_modules/path-to-regexp": {
- "version": "8.3.0",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
- "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
+ "version": "8.4.1",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.1.tgz",
+ "integrity": "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
- "node_modules/pathe": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
- "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/pathval": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
- "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 14.16"
- }
- },
"node_modules/pdfjs-dist": {
"version": "5.4.530",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.530.tgz",
@@ -7269,9 +7493,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7338,9 +7562,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.6",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
- "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"funding": [
{
"type": "opencollective",
@@ -7357,7 +7581,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.11",
+ "nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -7422,9 +7646,9 @@
}
},
"node_modules/qs": {
- "version": "6.14.1",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
- "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
+ "version": "6.15.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
+ "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -7534,6 +7758,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -7574,9 +7799,9 @@
"license": "MIT"
},
"node_modules/rollup": {
- "version": "4.56.0",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz",
- "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
+ "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7590,31 +7815,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.56.0",
- "@rollup/rollup-android-arm64": "4.56.0",
- "@rollup/rollup-darwin-arm64": "4.56.0",
- "@rollup/rollup-darwin-x64": "4.56.0",
- "@rollup/rollup-freebsd-arm64": "4.56.0",
- "@rollup/rollup-freebsd-x64": "4.56.0",
- "@rollup/rollup-linux-arm-gnueabihf": "4.56.0",
- "@rollup/rollup-linux-arm-musleabihf": "4.56.0",
- "@rollup/rollup-linux-arm64-gnu": "4.56.0",
- "@rollup/rollup-linux-arm64-musl": "4.56.0",
- "@rollup/rollup-linux-loong64-gnu": "4.56.0",
- "@rollup/rollup-linux-loong64-musl": "4.56.0",
- "@rollup/rollup-linux-ppc64-gnu": "4.56.0",
- "@rollup/rollup-linux-ppc64-musl": "4.56.0",
- "@rollup/rollup-linux-riscv64-gnu": "4.56.0",
- "@rollup/rollup-linux-riscv64-musl": "4.56.0",
- "@rollup/rollup-linux-s390x-gnu": "4.56.0",
- "@rollup/rollup-linux-x64-gnu": "4.56.0",
- "@rollup/rollup-linux-x64-musl": "4.56.0",
- "@rollup/rollup-openbsd-x64": "4.56.0",
- "@rollup/rollup-openharmony-arm64": "4.56.0",
- "@rollup/rollup-win32-arm64-msvc": "4.56.0",
- "@rollup/rollup-win32-ia32-msvc": "4.56.0",
- "@rollup/rollup-win32-x64-gnu": "4.56.0",
- "@rollup/rollup-win32-x64-msvc": "4.56.0",
+ "@rollup/rollup-android-arm-eabi": "4.60.1",
+ "@rollup/rollup-android-arm64": "4.60.1",
+ "@rollup/rollup-darwin-arm64": "4.60.1",
+ "@rollup/rollup-darwin-x64": "4.60.1",
+ "@rollup/rollup-freebsd-arm64": "4.60.1",
+ "@rollup/rollup-freebsd-x64": "4.60.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.1",
+ "@rollup/rollup-linux-arm64-musl": "4.60.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.1",
+ "@rollup/rollup-linux-loong64-musl": "4.60.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.1",
+ "@rollup/rollup-linux-x64-gnu": "4.60.1",
+ "@rollup/rollup-linux-x64-musl": "4.60.1",
+ "@rollup/rollup-openbsd-x64": "4.60.1",
+ "@rollup/rollup-openharmony-arm64": "4.60.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.1",
+ "@rollup/rollup-win32-x64-gnu": "4.60.1",
+ "@rollup/rollup-win32-x64-msvc": "4.60.1",
"fsevents": "~2.3.2"
}
},
@@ -7657,9 +7882,9 @@
"license": "MIT"
},
"node_modules/semver": {
- "version": "7.7.3",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
- "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
@@ -7742,48 +7967,53 @@
"license": "ISC"
},
"node_modules/sharp": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
- "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
+ "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"dev": true,
- "hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
- "@img/colour": "^1.0.0",
+ "@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
- "semver": "^7.7.3"
+ "semver": "^7.8.5"
},
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-darwin-arm64": "0.34.5",
- "@img/sharp-darwin-x64": "0.34.5",
- "@img/sharp-libvips-darwin-arm64": "1.2.4",
- "@img/sharp-libvips-darwin-x64": "1.2.4",
- "@img/sharp-libvips-linux-arm": "1.2.4",
- "@img/sharp-libvips-linux-arm64": "1.2.4",
- "@img/sharp-libvips-linux-ppc64": "1.2.4",
- "@img/sharp-libvips-linux-riscv64": "1.2.4",
- "@img/sharp-libvips-linux-s390x": "1.2.4",
- "@img/sharp-libvips-linux-x64": "1.2.4",
- "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
- "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
- "@img/sharp-linux-arm": "0.34.5",
- "@img/sharp-linux-arm64": "0.34.5",
- "@img/sharp-linux-ppc64": "0.34.5",
- "@img/sharp-linux-riscv64": "0.34.5",
- "@img/sharp-linux-s390x": "0.34.5",
- "@img/sharp-linux-x64": "0.34.5",
- "@img/sharp-linuxmusl-arm64": "0.34.5",
- "@img/sharp-linuxmusl-x64": "0.34.5",
- "@img/sharp-wasm32": "0.34.5",
- "@img/sharp-win32-arm64": "0.34.5",
- "@img/sharp-win32-ia32": "0.34.5",
- "@img/sharp-win32-x64": "0.34.5"
+ "@img/sharp-darwin-arm64": "0.35.3",
+ "@img/sharp-darwin-x64": "0.35.3",
+ "@img/sharp-freebsd-wasm32": "0.35.3",
+ "@img/sharp-libvips-darwin-arm64": "1.3.2",
+ "@img/sharp-libvips-darwin-x64": "1.3.2",
+ "@img/sharp-libvips-linux-arm": "1.3.2",
+ "@img/sharp-libvips-linux-arm64": "1.3.2",
+ "@img/sharp-libvips-linux-ppc64": "1.3.2",
+ "@img/sharp-libvips-linux-riscv64": "1.3.2",
+ "@img/sharp-libvips-linux-s390x": "1.3.2",
+ "@img/sharp-libvips-linux-x64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
+ "@img/sharp-linux-arm": "0.35.3",
+ "@img/sharp-linux-arm64": "0.35.3",
+ "@img/sharp-linux-ppc64": "0.35.3",
+ "@img/sharp-linux-riscv64": "0.35.3",
+ "@img/sharp-linux-s390x": "0.35.3",
+ "@img/sharp-linux-x64": "0.35.3",
+ "@img/sharp-linuxmusl-arm64": "0.35.3",
+ "@img/sharp-linuxmusl-x64": "0.35.3",
+ "@img/sharp-webcontainers-wasm32": "0.35.3",
+ "@img/sharp-win32-arm64": "0.35.3",
+ "@img/sharp-win32-ia32": "0.35.3",
+ "@img/sharp-win32-x64": "0.35.3"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
}
},
"node_modules/shebang-command": {
@@ -7808,9 +8038,9 @@
}
},
"node_modules/shell-quote": {
- "version": "1.8.3",
- "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
- "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
+ "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7892,13 +8122,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/siginfo": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
- "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
- "dev": true,
- "license": "ISC"
- },
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -7922,6 +8145,15 @@
"kolorist": "^1.6.0"
}
},
+ "node_modules/simple-swizzle": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
+ "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-arrayish": "^0.3.1"
+ }
+ },
"node_modules/simple-update-notifier": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
@@ -8040,13 +8272,6 @@
"node": ">=16"
}
},
- "node_modules/stackback": {
- "version": "0.0.2",
- "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
- "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -8056,13 +8281,6 @@
"node": ">= 0.8"
}
},
- "node_modules/std-env": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
- "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -8127,26 +8345,6 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
- "node_modules/strip-literal": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
- "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "js-tokens": "^9.0.1"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
- "node_modules/strip-literal/node_modules/js-tokens": {
- "version": "9.0.1",
- "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
- "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
@@ -8164,22 +8362,23 @@
}
},
"node_modules/svelte": {
- "version": "5.48.2",
- "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.48.2.tgz",
- "integrity": "sha512-VPWD+UyoSFZ7Nxix5K/F8yWiKWOiROkLlWYXOZReE0TUycw+58YWB3D6lAKT+57xmN99wRX4H3oZmw0NPy7y3Q==",
+ "version": "5.56.1",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.1.tgz",
+ "integrity": "sha512-eArsJmvl3xZVuTYD852PzIEdg2wgDdIZ1NEsIPbzAukHwi284B18No4nK2rCO9AwsWUDza4Cjvmoa4HaojTl5g==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
- "@sveltejs/acorn-typescript": "^1.0.5",
+ "@sveltejs/acorn-typescript": "^1.0.10",
"@types/estree": "^1.0.5",
+ "@types/trusted-types": "^2.0.7",
"acorn": "^8.12.1",
- "aria-query": "^5.3.1",
+ "aria-query": "5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
- "devalue": "^5.6.2",
+ "devalue": "^5.8.1",
"esm-env": "^1.2.1",
- "esrap": "^2.2.1",
+ "esrap": "^2.2.9",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
@@ -8190,9 +8389,9 @@
}
},
"node_modules/systeminformation": {
- "version": "5.30.6",
- "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.30.6.tgz",
- "integrity": "sha512-LEIyK1aEv5P3BhAPW3swdlIyCihxwEq/Gki+kcONieU4PIeRCSLDuGkk0Va/56PSBgjVgEksOM88dmY6YqOyfQ==",
+ "version": "5.31.7",
+ "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.7.tgz",
+ "integrity": "sha512-/8NC53e5nP9nmhn42/ncdOkyJnOoue/Vy+tJOyUGd1Yv66G069wK4rrziwhrqDETgk78CudTQupw5z19S5uoZw==",
"license": "MIT",
"os": [
"darwin",
@@ -8433,13 +8632,6 @@
"integrity": "sha512-k/CjiZ80bYss6Qs7/ex1TBlPD11whT9oKfT8oTGiHa34W4JRd1NiH/Tr1DbHWQ2/vMUypxksLnF2CfmlmM5XFQ==",
"license": "MIT"
},
- "node_modules/tinybench": {
- "version": "2.9.0",
- "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
- "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/tinycolor2": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
@@ -8447,13 +8639,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/tinyexec": {
- "version": "0.3.2",
- "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
- "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -8490,9 +8675,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -8502,36 +8687,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
- "node_modules/tinypool": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
- "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^18.0.0 || >=20.0.0"
- }
- },
- "node_modules/tinyrainbow": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
- "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/tinyspy": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
- "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=14.0.0"
- }
- },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -8631,7 +8786,6 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "dev": true,
"license": "0BSD"
},
"node_modules/tsutils": {
@@ -9175,17 +9329,34 @@
}
},
"node_modules/type-is": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
- "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+ "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
- "content-type": "^1.0.5",
+ "content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
- "node": ">= 0.6"
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/type-is/node_modules/content-type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
},
"node_modules/typedoc": {
@@ -9226,9 +9397,9 @@
}
},
"node_modules/typedoc/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
+ "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9236,13 +9407,13 @@
}
},
"node_modules/typedoc/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
- "brace-expansion": "^2.0.1"
+ "brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -9280,9 +9451,9 @@
"license": "MIT"
},
"node_modules/undici": {
- "version": "7.18.2",
- "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz",
- "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==",
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
+ "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
@@ -9345,9 +9516,9 @@
}
},
"node_modules/vite": {
- "version": "6.4.1",
- "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
- "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
+ "version": "6.4.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9419,29 +9590,6 @@
}
}
},
- "node_modules/vite-node": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
- "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "cac": "^6.7.14",
- "debug": "^4.4.1",
- "es-module-lexer": "^1.7.0",
- "pathe": "^2.0.3",
- "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
- },
- "bin": {
- "vite-node": "vite-node.mjs"
- },
- "engines": {
- "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
"node_modules/vite-plugin-singlefile": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.0.tgz",
@@ -9460,9 +9608,9 @@
}
},
"node_modules/vite-plugin-solid": {
- "version": "2.11.10",
- "resolved": "https://registry.npmjs.org/vite-plugin-solid/-/vite-plugin-solid-2.11.10.tgz",
- "integrity": "sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw==",
+ "version": "2.11.12",
+ "resolved": "https://registry.npmjs.org/vite-plugin-solid/-/vite-plugin-solid-2.11.12.tgz",
+ "integrity": "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9476,7 +9624,7 @@
"peerDependencies": {
"@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*",
"solid-js": "^1.7.2",
- "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@testing-library/jest-dom": {
@@ -9485,9 +9633,9 @@
}
},
"node_modules/vite-prerender-plugin": {
- "version": "0.5.12",
- "resolved": "https://registry.npmjs.org/vite-prerender-plugin/-/vite-prerender-plugin-0.5.12.tgz",
- "integrity": "sha512-EiwhbMn+flg14EysbLTmZSzq8NGTxhytgK3bf4aGRF1evWLGwZiHiUJ1KZDvbxgKbMf2pG6fJWGEa3UZXOnR1g==",
+ "version": "0.5.13",
+ "resolved": "https://registry.npmjs.org/vite-prerender-plugin/-/vite-prerender-plugin-0.5.13.tgz",
+ "integrity": "sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9499,7 +9647,7 @@
"stack-trace": "^1.0.0-pre2"
},
"peerDependencies": {
- "vite": "5.x || 6.x || 7.x"
+ "vite": "5.x || 6.x || 7.x || 8.x"
}
},
"node_modules/vite/node_modules/fdir": {
@@ -9521,9 +9669,9 @@
}
},
"node_modules/vite/node_modules/picomatch": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -9553,92 +9701,6 @@
}
}
},
- "node_modules/vitest": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
- "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/chai": "^5.2.2",
- "@vitest/expect": "3.2.4",
- "@vitest/mocker": "3.2.4",
- "@vitest/pretty-format": "^3.2.4",
- "@vitest/runner": "3.2.4",
- "@vitest/snapshot": "3.2.4",
- "@vitest/spy": "3.2.4",
- "@vitest/utils": "3.2.4",
- "chai": "^5.2.0",
- "debug": "^4.4.1",
- "expect-type": "^1.2.1",
- "magic-string": "^0.30.17",
- "pathe": "^2.0.3",
- "picomatch": "^4.0.2",
- "std-env": "^3.9.0",
- "tinybench": "^2.9.0",
- "tinyexec": "^0.3.2",
- "tinyglobby": "^0.2.14",
- "tinypool": "^1.1.1",
- "tinyrainbow": "^2.0.0",
- "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
- "vite-node": "3.2.4",
- "why-is-node-running": "^2.3.0"
- },
- "bin": {
- "vitest": "vitest.mjs"
- },
- "engines": {
- "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "@edge-runtime/vm": "*",
- "@types/debug": "^4.1.12",
- "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
- "@vitest/browser": "3.2.4",
- "@vitest/ui": "3.2.4",
- "happy-dom": "*",
- "jsdom": "*"
- },
- "peerDependenciesMeta": {
- "@edge-runtime/vm": {
- "optional": true
- },
- "@types/debug": {
- "optional": true
- },
- "@types/node": {
- "optional": true
- },
- "@vitest/browser": {
- "optional": true
- },
- "@vitest/ui": {
- "optional": true
- },
- "happy-dom": {
- "optional": true
- },
- "jsdom": {
- "optional": true
- }
- }
- },
- "node_modules/vitest/node_modules/picomatch": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
"node_modules/vue": {
"version": "3.5.27",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz",
@@ -9697,23 +9759,6 @@
"node": ">= 8"
}
},
- "node_modules/why-is-node-running": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
- "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "siginfo": "^2.0.0",
- "stackback": "0.0.2"
- },
- "bin": {
- "why-is-node-running": "cli.js"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/widest-line": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz",
@@ -9799,9 +9844,9 @@
"license": "ISC"
},
"node_modules/yaml": {
- "version": "2.8.2",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
- "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
+ "version": "2.8.3",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
+ "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"dev": true,
"license": "ISC",
"bin": {
@@ -9859,12 +9904,13 @@
}
},
"node_modules/zod-to-json-schema": {
- "version": "3.25.1",
- "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
- "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
+ "version": "3.25.2",
+ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
+ "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
+ "dev": true,
"license": "ISC",
"peerDependencies": {
- "zod": "^3.25 || ^4"
+ "zod": "^3.25.28 || ^4"
}
}
}
diff --git a/package.json b/package.json
index 6a2d0fa..c86efa9 100644
--- a/package.json
+++ b/package.json
@@ -2,13 +2,16 @@
"name": "@modelcontextprotocol/ext-apps",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps"
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git"
},
"homepage": "https://github.com/modelcontextprotocol/ext-apps",
- "version": "1.1.2",
+ "version": "2.0.0",
"license": "MIT",
"description": "MCP Apps SDK โ Enable MCP servers to display interactive user interfaces in conversational clients.",
"type": "module",
+ "engines": {
+ "node": ">=20"
+ },
"main": "./dist/src/app.js",
"exports": {
".": {
@@ -44,14 +47,14 @@
"examples/*"
],
"scripts": {
- "postinstall": "node scripts/setup-bun.mjs || echo 'setup-bun.mjs failed or not available'",
"start": "npm run examples:dev",
"generate:schemas": "tsx scripts/generate-schemas.ts && prettier --write \"src/generated/**/*\"",
"sync:snippets": "bun scripts/sync-snippets.ts",
"build": "npm run generate:schemas && npm run sync:snippets && node scripts/run-bun.mjs build.bun.ts && node scripts/link-self.mjs",
"prepack": "npm run build",
"build:all": "npm run examples:build",
- "test": "bun test src",
+ "test": "bun test src examples",
+ "test:dependency-isolation": "npm run build && node scripts/check-dependency-isolation.mjs",
"test:e2e": "playwright test",
"test:e2e:update": "playwright test --update-snapshots",
"test:e2e:ui": "playwright test --ui",
@@ -62,24 +65,30 @@
"examples:start": "NODE_ENV=development npm run build && bun examples/run-all.ts start",
"examples:dev": "NODE_ENV=development bun examples/run-all.ts dev",
"watch": "nodemon --watch src --ext ts,tsx --exec 'bun build.bun.ts'",
- "prepare": "node scripts/setup-bun.mjs && npm run build && husky",
+ "prepare": "npm run build && husky",
"docs": "typedoc",
"docs:watch": "typedoc --watch",
- "generate:screenshots": "npm run build:all && docker run --rm -e EXAMPLE -v $(pwd):/work -w /work mcr.microsoft.com/playwright:v1.57.0-noble sh -c 'apt-get update -qq && apt-get install -qq -y python3-venv curl > /dev/null && curl -LsSf https://astral.sh/uv/install.sh | sh && export PATH=\"$HOME/.local/bin:$PATH\" && npm i -g bun && npm ci && npx playwright test tests/e2e/generate-grid-screenshots.spec.ts'",
+ "generate:screenshots": "npm run build:all && docker run --rm -e EXAMPLE -e GENERATE_SCREENSHOTS=1 -v $(pwd):/work -w /work mcr.microsoft.com/playwright:v1.57.0-noble sh -c 'apt-get update -qq && apt-get install -qq -y python3-venv curl > /dev/null && curl -LsSf https://astral.sh/uv/install.sh | sh && export PATH=\"$HOME/.local/bin:$PATH\" && npm i -g bun && npm ci && npx playwright test tests/e2e/generate-grid-screenshots.spec.ts'",
"prettier": "prettier -u \"**/*.{js,jsx,ts,tsx,mjs,json,md,yml,yaml}\" --check",
"prettier:fix": "prettier -u \"**/*.{js,jsx,ts,tsx,mjs,json,md,yml,yaml}\" --write",
"check:versions": "node scripts/check-versions.mjs",
+ "bump": "node scripts/bump-version.mjs",
"update-lock:docker": "rm -rf node_modules package-lock.json examples/*/node_modules && docker run --rm --platform linux/amd64 -v $(pwd):/work -w /work -e HOME=/tmp node:latest npm i --registry=https://registry.npmjs.org/ --ignore-scripts && rm -rf node_modules examples/*/node_modules && npm i --registry=https://registry.npmjs.org/"
},
"author": "Olivier Chafik",
+ "//interop": "The @modelcontextprotocol/sdk 1.x and ext-apps-v1 devDependencies exist only for src/interop-v1.test.ts, which runs the published 1.7.5 package against this build. The library does not use them.",
"devDependencies": {
"@boneskull/typedoc-plugin-mermaid": "^0.2.0",
- "@modelcontextprotocol/sdk": "1.25.2",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/core": "2.0.0",
+ "@modelcontextprotocol/sdk": "1.30.0",
+ "@modelcontextprotocol/server": "2.0.0",
"@playwright/test": "1.57.0",
"@types/bun": "^1.3.2",
+ "@types/node": "20.19.27",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
- "@types/node": "20.19.27",
+ "bun": "^1.2.21",
"caniuse-lite": "1.0.30001763",
"cheerio": "1.1.2",
"concurrently": "^9.2.1",
@@ -88,6 +97,7 @@
"electron-to-chromium": "1.5.267",
"esbuild": "^0.25.12",
"express": "^5.1.0",
+ "ext-apps-v1": "npm:@modelcontextprotocol/ext-apps@1.7.5",
"husky": "^9.1.7",
"nodemon": "^3.1.0",
"playwright": "1.57.0",
@@ -95,21 +105,26 @@
"prettier": "^3.6.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
- "sharp": "^0.34.5",
+ "sharp": "^0.35.3",
"ts-to-zod": "^5.1.0",
"tsx": "^4.21.0",
"typedoc": "^0.28.14",
"typedoc-github-theme": "^0.4.0",
"typescript": "^5.9.3",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"peerDependencies": {
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "^2.0.0",
+ "@modelcontextprotocol/core": "^2.0.0",
+ "@modelcontextprotocol/server": "^2.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
- "zod": "^3.25.0 || ^4.0.0"
+ "zod": "^4.2.0"
},
"peerDependenciesMeta": {
+ "@modelcontextprotocol/server": {
+ "optional": true
+ },
"react": {
"optional": true
},
@@ -117,30 +132,13 @@
"optional": true
}
},
- "optionalDependencies": {
- "@oven/bun-darwin-aarch64": "^1.2.21",
- "@oven/bun-darwin-x64": "^1.2.21",
- "@oven/bun-darwin-x64-baseline": "^1.2.21",
- "@oven/bun-linux-aarch64": "^1.2.21",
- "@oven/bun-linux-aarch64-musl": "^1.2.21",
- "@oven/bun-linux-x64": "^1.2.21",
- "@oven/bun-linux-x64-baseline": "^1.2.21",
- "@oven/bun-linux-x64-musl": "^1.2.21",
- "@oven/bun-linux-x64-musl-baseline": "^1.2.21",
- "@oven/bun-windows-x64": "^1.2.21",
- "@oven/bun-windows-x64-baseline": "^1.2.21",
- "@rollup/rollup-darwin-arm64": "^4.53.3",
- "@rollup/rollup-darwin-x64": "^4.53.3",
- "@rollup/rollup-linux-arm64-gnu": "^4.53.3",
- "@rollup/rollup-linux-x64-gnu": "^4.53.3",
- "@rollup/rollup-win32-arm64-msvc": "^4.53.3",
- "@rollup/rollup-win32-x64-msvc": "^4.53.3"
- },
"overrides": {
"seroval": "1.4.1",
"seroval-plugins": "1.4.2",
"solid-js": "1.9.10",
- "@hono/node-server": "1.19.7",
"@types/node": "20.19.27"
+ },
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0"
}
}
diff --git a/playwright.config.ts b/playwright.config.ts
index 1453521..ecf1e13 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -2,6 +2,13 @@ import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
+ // Exclude the screenshot generation spec from default runs.
+ // It writes examples/*/grid-cell.png as a side effect and is meant to be
+ // invoked only via `npm run generate:screenshots` (which sets
+ // GENERATE_SCREENSHOTS=1 to bypass this ignore).
+ testIgnore: process.env.GENERATE_SCREENSHOTS
+ ? []
+ : ["**/generate-grid-screenshots.spec.ts"],
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
@@ -23,6 +30,7 @@ export default defineConfig({
...devices["Desktop Chrome"],
// Use default Chromium everywhere for consistent screenshot rendering
// Run `npm run test:e2e:docker` locally for CI-identical results
+ ...(process.env.PW_CHANNEL ? { channel: process.env.PW_CHANNEL } : {}),
},
},
],
@@ -39,6 +47,10 @@ export default defineConfig({
env: {
...process.env,
EXAMPLE: process.env.EXAMPLE ?? "",
+ // Let pdf-server fetch from the http://127.0.0.1 range-counting fixture
+ // (validateUrl rejects loopback HTTP unless this is set). Scoped to this
+ // server's check only โ does not touch Node's TLS verification.
+ PDF_SERVER_ALLOW_LOOPBACK_HTTP: "1",
},
},
// Snapshot configuration
diff --git a/plugins/mcp-apps/skills/add-app-to-server/SKILL.md b/plugins/mcp-apps/skills/add-app-to-server/SKILL.md
index 02d09a4..5a473f0 100644
--- a/plugins/mcp-apps/skills/add-app-to-server/SKILL.md
+++ b/plugins/mcp-apps/skills/add-app-to-server/SKILL.md
@@ -77,13 +77,28 @@ Before writing any code, analyze the server's existing tools and determine which
## Step 2: Add Dependencies
```bash
-npm install @modelcontextprotocol/ext-apps
+npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/client@^2.0.0 @modelcontextprotocol/server@^2.0.0 zod@^4.2.0
npm install -D vite vite-plugin-singlefile
```
-Plus framework-specific dependencies if needed (e.g., `react`, `react-dom`, `@vitejs/plugin-react` for React).
+Plus framework-specific dependencies if needed (e.g., `react`, `react-dom`, `@vitejs/plugin-react` for React), and `@modelcontextprotocol/node` / `@modelcontextprotocol/express` if the server uses HTTP transports.
-Use `npm install` to add dependencies rather than manually writing version numbers. This lets npm resolve the latest compatible versions. Never specify version numbers from memory.
+ext-apps 2.x requires the split base MCP SDK packages at `^2.0.0`
+(`@modelcontextprotocol/core` comes in transitively). Existing servers that
+still import `@modelcontextprotocol/sdk` v1 must migrate those imports and
+handler schemas before adding the App integration:
+
+| SDK v1 (`@modelcontextprotocol/sdk`) | SDK v2 |
+|---|---|
+| `sdk/server/mcp.js` (`McpServer`) | `@modelcontextprotocol/server` |
+| `sdk/server/streamableHttp.js` (`StreamableHTTPServerTransport`) | `NodeStreamableHTTPServerTransport` from `@modelcontextprotocol/node` |
+| Express wiring by hand | `createMcpExpressApp` from `@modelcontextprotocol/express` |
+| `sdk/server/stdio.js` | `@modelcontextprotocol/server/stdio` |
+| `sdk/types.js` types (`CallToolResult`, โฆ) | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` |
+| `sdk/types.js` zod schemas (`CallToolResultSchema`, โฆ) | `@modelcontextprotocol/core` |
+| Raw zod shapes: `inputSchema: { q: z.string() }` | `inputSchema: z.object({ q: z.string() })` |
+| `extra.signal` in tool callbacks | `extra.mcpReq.signal` |
+| `setRequestHandler(SomeRequestSchema, handler)` | `setRequestHandler("some/method", { params: ParamsSchema }, handler)` (the 2-arg form is only for spec methods such as `"tools/call"`) |
## Step 3: Set Up the Build Pipeline
@@ -146,10 +161,14 @@ Transform plain MCP tools into App tools with UI.
**Before** (plain MCP tool):
```typescript
-server.tool("my-tool", { param: z.string() }, async (args) => {
- const data = await fetchData(args.param);
- return { content: [{ type: "text", text: JSON.stringify(data) }] };
-});
+server.registerTool(
+ "my-tool",
+ { inputSchema: z.object({ param: z.string() }) },
+ async ({ param }) => {
+ const data = await fetchData(param);
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
+ },
+);
```
**After** (App tool with UI):
@@ -158,17 +177,22 @@ import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@model
const resourceUri = "ui://my-tool/mcp-app.html";
-registerAppTool(server, "my-tool", {
- description: "Shows data with an interactive UI",
- inputSchema: { param: z.string() },
- _meta: { ui: { resourceUri } },
-}, async (args) => {
- const data = await fetchData(args.param);
- return {
- content: [{ type: "text", text: JSON.stringify(data) }], // text fallback for non-UI hosts
- structuredContent: { data }, // structured data for the UI
- };
-});
+registerAppTool(
+ server,
+ "my-tool",
+ {
+ description: "Shows data with an interactive UI",
+ inputSchema: z.object({ param: z.string() }),
+ _meta: { ui: { resourceUri } },
+ },
+ async ({ param }) => {
+ const data = await fetchData(param);
+ return {
+ content: [{ type: "text", text: JSON.stringify(data) }], // text fallback for non-UI hosts
+ structuredContent: { data }, // structured data for the UI
+ };
+ },
+);
```
Key guidance:
@@ -187,17 +211,21 @@ import path from "node:path";
const resourceUri = "ui://my-tool/mcp-app.html";
-registerAppResource(server, {
- uri: resourceUri,
- name: "My Tool UI",
- mimeType: RESOURCE_MIME_TYPE,
-}, async () => {
- const html = await fs.readFile(
- path.resolve(import.meta.dirname, "dist", "mcp-app.html"),
- "utf-8",
- );
- return { contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }] };
-});
+registerAppResource(
+ server,
+ "My Tool UI",
+ resourceUri,
+ {},
+ async () => {
+ const html = await fs.readFile(
+ path.resolve(import.meta.dirname, "dist", "mcp-app.html"),
+ "utf-8",
+ );
+ return {
+ contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }],
+ };
+ },
+);
```
If multiple tools share the same UI, they can reference the same `resourceUri` and the same resource registration.
@@ -271,25 +299,32 @@ registerAppTool(server, "poll-data", {
});
```
-The UI calls these via `app.callServerTool("poll-data", {})`.
+The UI calls these via
+`app.callServerTool({ name: "poll-data", arguments: {} })`.
### CSP Configuration
If the UI needs to load external resources (fonts, APIs, CDNs), declare the domains:
```typescript
-registerAppResource(server, {
- uri: resourceUri,
- name: "My Tool UI",
- mimeType: RESOURCE_MIME_TYPE,
- _meta: {
- ui: {
- connectDomains: ["api.example.com"], // fetch/XHR targets
- resourceDomains: ["cdn.example.com"], // scripts, styles, images
- frameDomains: ["embed.example.com"], // nested iframes
+registerAppResource(server, "My Tool UI", resourceUri, {}, async () => ({
+ contents: [
+ {
+ uri: resourceUri,
+ mimeType: RESOURCE_MIME_TYPE,
+ text: html,
+ _meta: {
+ ui: {
+ csp: {
+ connectDomains: ["https://api.example.com"], // fetch/XHR targets
+ resourceDomains: ["https://cdn.example.com"], // scripts, styles, images
+ frameDomains: ["https://embed.example.com"], // nested iframes
+ },
+ },
+ },
},
- },
-}, async () => { /* ... */ });
+ ],
+}));
```
### Streaming Partial Input
@@ -326,7 +361,14 @@ server.server.oninitialized = () => {
}, appToolHandler);
} else {
// Text-only client โ register plain tool
- server.tool("my-tool", "Shows data", { param: z.string() }, plainToolHandler);
+ server.registerTool(
+ "my-tool",
+ {
+ description: "Shows data",
+ inputSchema: z.object({ param: z.string() }),
+ },
+ plainToolHandler,
+ );
}
};
```
diff --git a/plugins/mcp-apps/skills/convert-web-app/SKILL.md b/plugins/mcp-apps/skills/convert-web-app/SKILL.md
index 5db2d94..8fb7fa0 100644
--- a/plugins/mcp-apps/skills/convert-web-app/SKILL.md
+++ b/plugins/mcp-apps/skills/convert-web-app/SKILL.md
@@ -115,19 +115,22 @@ Create a new MCP server with tool and resource registration. This wraps the exis
### Dependencies
```bash
-npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod
+npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/client@^2.0.0 @modelcontextprotocol/server@^2.0.0 zod@^4.2.0
npm install -D tsx vite vite-plugin-singlefile
```
-Use `npm install` to add dependencies rather than manually writing version numbers. This lets npm resolve the latest compatible versions. Never specify version numbers from memory.
+Use `npm install` so the package manager resolves versions; ext-apps 2.x needs
+the split base MCP SDK packages at `^2.0.0` (`@modelcontextprotocol/core` comes
+in transitively). Do not add the legacy `@modelcontextprotocol/sdk` v1 package
+or substitute unpublished local packages.
### Server Code
Create `server.ts`:
```typescript
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+import { McpServer } from "@modelcontextprotocol/server";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
import fs from "node:fs/promises";
import path from "node:path";
@@ -140,7 +143,9 @@ const resourceUri = "ui://my-app/mcp-app.html";
// Register the tool โ inputSchema maps to the app's data sources
registerAppTool(server, "show-app", {
description: "Displays the app with the given parameters",
- inputSchema: { query: z.string().describe("The search query") },
+ inputSchema: z.object({
+ query: z.string().describe("The search query"),
+ }),
_meta: { ui: { resourceUri } },
}, async (args) => {
// Process args server-side if needed
@@ -151,19 +156,29 @@ registerAppTool(server, "show-app", {
});
// Register the HTML resource
-registerAppResource(server, {
- uri: resourceUri,
- name: "My App UI",
- mimeType: RESOURCE_MIME_TYPE,
- // Add CSP domains from Step 2 if needed:
- // _meta: { ui: { connectDomains: ["api.example.com"], resourceDomains: ["cdn.example.com"] } },
-}, async () => {
- const html = await fs.readFile(
- path.resolve(import.meta.dirname, "dist", "mcp-app.html"),
- "utf-8",
- );
- return { contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }] };
-});
+registerAppResource(
+ server,
+ "My App UI",
+ resourceUri,
+ {},
+ async () => {
+ const html = await fs.readFile(
+ path.resolve(import.meta.dirname, "dist", "mcp-app.html"),
+ "utf-8",
+ );
+ return {
+ contents: [
+ {
+ uri: resourceUri,
+ mimeType: RESOURCE_MIME_TYPE,
+ text: html,
+ // Add CSP domains from Step 2 if needed:
+ // _meta: { ui: { csp: { connectDomains: ["https://api.example.com"], resourceDomains: ["https://cdn.example.com"] } } },
+ },
+ ],
+ };
+ },
+);
// Start the server
const transport = new StdioServerTransport();
@@ -310,7 +325,10 @@ const data = await fetch("/api/data").then(r => r.json());
// After (hybrid):
async function fetchData(): Promise {
if (isMcpApp) {
- const result = await app.callServerTool("fetch-data", {});
+ const result = await app.callServerTool({
+ name: "fetch-data",
+ arguments: {},
+ });
return result.structuredContent;
}
return fetch("/api/data").then(r => r.json());
@@ -437,7 +455,8 @@ registerAppTool(server, "refresh-data", {
});
```
-The UI calls these via `app.callServerTool("refresh-data", {})`.
+The UI calls these via
+`app.callServerTool({ name: "refresh-data", arguments: {} })`.
### Streaming Partial Input
diff --git a/plugins/mcp-apps/skills/create-mcp-app/SKILL.md b/plugins/mcp-apps/skills/create-mcp-app/SKILL.md
index f1a8375..d73dc90 100644
--- a/plugins/mcp-apps/skills/create-mcp-app/SKILL.md
+++ b/plugins/mcp-apps/skills/create-mcp-app/SKILL.md
@@ -13,11 +13,10 @@ Every MCP App requires two parts linked together:
1. **Tool** - Called by the LLM/host, returns data
2. **Resource** - Serves the bundled HTML UI that displays the data
-3. **Link** - The tool's `_meta.ui.resourceUri` references the resource
-```
-Host calls tool โ Server returns result โ Host renders resource UI โ UI receives result
-```
+The tool's `_meta.ui.resourceUri` references the resource's URI.
+
+Host calls tool โ Host renders resource UI โ Server returns result โ UI receives result.
## Quick Start Decision Tree
@@ -63,10 +62,12 @@ Learn and adapt from `/tmp/mcp-ext-apps/examples/basic-server-{framework}/`:
| `basic-server-solid/` | `server.ts`, `src/mcp-app.tsx` |
Each template includes:
-- Complete `server.ts` with `registerAppTool` and `registerAppResource`
-- Client-side app with all lifecycle handlers
-- `vite.config.ts` with `vite-plugin-singlefile`
-- `package.json` with all required dependencies
+- `server.ts` with `registerAppTool` and `registerAppResource`
+- `main.ts` entry point with HTTP and stdio transport setup
+- Client-side app (e.g., `src/mcp-app.ts`, `src/mcp-app.tsx`) with lifecycle handlers
+- `src/global.css` with global styles and host style variable fallbacks
+- `vite.config.ts` using `vite-plugin-singlefile`
+- `package.json` with `npm run` scripts and required dependencies
- `.gitignore` excluding `node_modules/` and `dist/`
### API Reference (Source Files)
@@ -75,54 +76,61 @@ Read JSDoc documentation directly from `/tmp/mcp-ext-apps/src/`:
| File | Contents |
|------|----------|
-| `src/app.ts` | `App` class, handlers (`ontoolinput`, `ontoolresult`, `onhostcontextchanged`, `onteardown`), lifecycle |
-| `src/server/index.ts` | `registerAppTool`, `registerAppResource`, tool visibility options |
-| `src/spec.types.ts` | All type definitions: `McpUiHostContext`, CSS variable keys, display modes |
+| `src/app.ts` | `App` class, handlers (`ontoolinput`, `ontoolresult`, `onhostcontextchanged`, `onteardown`, etc.), lifecycle |
+| `src/server/index.ts` | `registerAppTool`, `registerAppResource`, helper functions |
+| `src/spec.types.ts` | All type definitions: `McpUiHostContext`, `McpUiStyleVariableKey` (CSS variable names), `McpUiResourceCsp` (CSP configuration), etc. |
| `src/styles.ts` | `applyDocumentTheme`, `applyHostStyleVariables`, `applyHostFonts` |
| `src/react/useApp.tsx` | `useApp` hook for React apps |
-| `src/react/useHostStyles.ts` | `useHostStyles`, `useHostStyleVariables`, `useHostFonts` hooks |
-### Advanced Examples
+### Advanced Patterns
-| Example | Pattern Demonstrated |
-|---------|---------------------|
-| `examples/shadertoy-server/` | **Streaming partial input** + visibility-based pause/play (best practice for large inputs) |
-| `examples/wiki-explorer-server/` | `callServerTool` for interactive data fetching |
-| `examples/system-monitor-server/` | Polling pattern with interval management |
-| `examples/video-resource-server/` | Binary/blob resources |
-| `examples/sheet-music-server/` | `ontoolinput` - processing tool args before execution completes |
-| `examples/threejs-server/` | `ontoolinputpartial` - streaming/progressive rendering |
-| `examples/map-server/` | `updateModelContext` - keeping model informed of UI state |
-| `examples/transcript-server/` | `updateModelContext` + `sendMessage` - background context updates + user-initiated messages |
-| `examples/basic-host/` | Reference host implementation using `AppBridge` |
+See `/tmp/mcp-ext-apps/docs/patterns.md` for detailed recipes:
+
+- **App-only tools** โ `visibility: ["app"]`, hiding tools from model
+- **Polling** โ real-time dashboards, interval management
+- **Chunked responses** โ large files, pagination, base64 encoding
+- **Error handling** โ `isError`, informing model of failures
+- **Binary resources** โ audio/video/etc via `resources/read`, blob field
+- **Network requests** โ assets, fetch, CSP, `_meta.ui.csp`, CORS, `_meta.ui.domain`
+- **Host context** โ theme, styling, fonts, safe area insets
+- **Fullscreen mode** โ `requestDisplayMode`, display mode changes
+- **Model context** โ `updateModelContext`, `sendMessage`, keeping model informed
+- **View state** โ `viewUUID`, localStorage, state recovery
+- **Visibility-based pause** โ IntersectionObserver, pausing animations/WebGL
+- **Streaming input** โ `ontoolinputpartial`, progressive rendering
+
+### Reference Host Implementation
+
+`/tmp/mcp-ext-apps/examples/basic-host/` shows one way an MCP Apps-capable host could be implemented. Real-world hosts like Claude Desktop are more sophisticatedโuse basic-host for local testing and protocol understanding, not as a guarantee of host behavior.
## Critical Implementation Notes
### Adding Dependencies
-Use `npm install` to add dependencies rather than manually writing version numbers:
+**Always** use `npm install` to add dependencies rather than manually writing version numbers:
```bash
-npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod
+npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/client@^2.0.0 @modelcontextprotocol/server@^2.0.0 @modelcontextprotocol/node@^2.0.0 @modelcontextprotocol/express@^2.0.0 zod@^4.2.0 express cors
+npm install -D typescript vite vite-plugin-singlefile concurrently cross-env @types/node @types/express @types/cors
```
-This lets npm resolve the latest compatible versions. Never specify version numbers from memory.
+ext-apps 2.x requires the split base MCP SDK packages (`@modelcontextprotocol/client`,
+`server`, `node`, `express`) at `^2.0.0`; `@modelcontextprotocol/core` comes in
+transitively. Do not add the legacy `@modelcontextprotocol/sdk` v1 package or
+substitute unpublished local packages.
### TypeScript Server Execution
-Use `tsx` as a devDependency for running TypeScript server files:
+Unless the user has specified otherwise, use `tsx` for running TypeScript server files. For example:
```bash
npm install -D tsx
+
+npm pkg set scripts.dev="cross-env NODE_ENV=development concurrently 'cross-env INPUT=mcp-app.html vite build --watch' 'tsx --watch main.ts'"
```
-```json
-"scripts": {
- "serve": "tsx server.ts"
-}
-```
-
-Note: The SDK examples use `bun` but generated projects should use `tsx` for broader compatibility.
+> [!NOTE]
+> The SDK examples use `bun` but generated projects should default to `tsx` for broader compatibility.
### Handler Registration Order
@@ -136,182 +144,19 @@ app.ontoolinput = (params) => { /* handle input */ };
app.ontoolresult = (result) => { /* handle result */ };
app.onhostcontextchanged = (ctx) => { /* handle context */ };
app.onteardown = async () => { return {}; };
+// etc.
// Then connect
await app.connect();
```
-### Tool Visibility
-
-Control who can access tools via `_meta.ui.visibility`:
-
-```typescript
-// Default: visible to both model and app
-_meta: { ui: { resourceUri, visibility: ["model", "app"] } }
-
-// UI-only (hidden from model) - for refresh buttons, form submissions
-_meta: { ui: { resourceUri, visibility: ["app"] } }
-
-// Model-only (app cannot call)
-_meta: { ui: { resourceUri, visibility: ["model"] } }
-```
-
-### Host Styling Integration
-
-**Vanilla JS** - Use helper functions:
-```typescript
-import { applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps";
-
-app.onhostcontextchanged = (ctx) => {
- if (ctx.theme) applyDocumentTheme(ctx.theme);
- if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
- if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts);
-};
-```
-
-**React** - Use hooks:
-```typescript
-import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";
-
-const { app } = useApp({ appInfo, capabilities, onAppCreated });
-useHostStyles(app); // Injects CSS variables to document, making var(--*) available
-```
-
-**Using variables in CSS** - After applying, use `var()`:
-```css
-.container {
- background: var(--color-background-secondary);
- color: var(--color-text-primary);
- font-family: var(--font-sans);
- border-radius: var(--border-radius-md);
-}
-.code {
- font-family: var(--font-mono);
- font-size: var(--font-text-sm-size);
- line-height: var(--font-text-sm-line-height);
- color: var(--color-text-secondary);
-}
-.heading {
- font-size: var(--font-heading-lg-size);
- font-weight: var(--font-weight-semibold);
-}
-```
-
-Key variable groups: `--color-background-*`, `--color-text-*`, `--color-border-*`, `--font-sans`, `--font-mono`, `--font-text-*-size`, `--font-heading-*-size`, `--border-radius-*`. See `src/spec.types.ts` for full list.
-
-### Safe Area Handling
-
-Always respect `safeAreaInsets`:
-
-```typescript
-app.onhostcontextchanged = (ctx) => {
- if (ctx.safeAreaInsets) {
- const { top, right, bottom, left } = ctx.safeAreaInsets;
- document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`;
- }
-};
-```
-
-### Streaming Partial Input
-
-For large tool inputs, use `ontoolinputpartial` to show progress during LLM generation. The partial JSON is healed (always valid), enabling progressive UI updates.
-
-**Spec:** [ui/notifications/tool-input-partial](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#streaming-tool-input)
-
-```typescript
-app.ontoolinputpartial = (params) => {
- const args = params.arguments; // Healed partial JSON - always valid, fields appear as generated
- // Use args directly for progressive rendering
-};
-
-app.ontoolinput = (params) => {
- // Final complete input - switch from preview to full render
-};
-```
-
-**Use cases:**
-| Pattern | Example |
-|---------|---------|
-| Code preview | Show streaming code in ``, render on complete (`examples/shadertoy-server/`) |
-| Progressive form | Fill form fields as they stream in |
-| Live chart | Add data points to chart as array grows |
-| Partial render | Render incomplete structured data (tables, lists, trees) |
-
-**Simple pattern (code preview):**
-```typescript
-app.ontoolinputpartial = (params) => {
- codePreview.textContent = params.arguments?.code ?? "";
- codePreview.style.display = "block";
- canvas.style.display = "none";
-};
-app.ontoolinput = (params) => {
- codePreview.style.display = "none";
- canvas.style.display = "block";
- render(params.arguments);
-};
-```
-
-### Visibility-Based Resource Management
-
-Pause expensive operations (animations, WebGL, polling) when view scrolls out of viewport:
-
-```typescript
-const observer = new IntersectionObserver((entries) => {
- entries.forEach((entry) => {
- if (entry.isIntersecting) {
- animation.play(); // or: startPolling(), shaderToy.play()
- } else {
- animation.pause(); // or: stopPolling(), shaderToy.pause()
- }
- });
-});
-observer.observe(document.querySelector(".main"));
-```
-
-### Fullscreen Mode
-
-Request fullscreen via `app.requestDisplayMode()`. Check availability in host context:
-
-```typescript
-let currentMode: "inline" | "fullscreen" = "inline";
-
-app.onhostcontextchanged = (ctx) => {
- // Check if fullscreen available
- if (ctx.availableDisplayModes?.includes("fullscreen")) {
- fullscreenBtn.style.display = "block";
- }
- // Track current mode
- if (ctx.displayMode) {
- currentMode = ctx.displayMode;
- container.classList.toggle("fullscreen", currentMode === "fullscreen");
- }
-};
-
-async function toggleFullscreen() {
- const newMode = currentMode === "fullscreen" ? "inline" : "fullscreen";
- const result = await app.requestDisplayMode({ mode: newMode });
- currentMode = result.mode;
-}
-```
-
-**CSS pattern** - Remove border radius in fullscreen:
-```css
-.main { border-radius: var(--border-radius-lg); overflow: hidden; }
-.main.fullscreen { border-radius: 0; }
-```
-
-See `examples/shadertoy-server/` for complete implementation.
-
## Common Mistakes to Avoid
-1. **Handlers after connect()** - Register ALL handlers BEFORE calling `app.connect()`
-2. **Missing single-file bundling** - Must use `vite-plugin-singlefile`
-3. **Forgetting resource registration** - Both tool AND resource must be registered
-4. **Missing resourceUri link** - Tool must have `_meta.ui.resourceUri`
-5. **Ignoring safe area insets** - Always handle `ctx.safeAreaInsets`
-6. **No text fallback** - Always provide `content` array for non-UI hosts
-7. **Hardcoded styles** - Use host CSS variables for theme integration
-8. **No streaming for large inputs** - Use `ontoolinputpartial` to show progress during generation
+1. **No text fallback** - Always provide `content` array for non-UI hosts
+2. **Missing CSP configuration** - MCP Apps HTML is served as an MCP resource with no same-origin server; ALL network requestsโeven to `localhost`โrequire a CSP configuration
+3. **CSP or CORS config in wrong _meta object** - `_meta.ui.csp` and `_meta.ui.domain` go in the `contents[]` objects returned by `registerAppResource()`'s read callback, not in `registerAppResource()`'s config object
+4. **Handlers after app.connect()** - Register ALL handlers BEFORE calling `app.connect()`
+5. **No streaming for large inputs** - Use `ontoolinputpartial` to show progress during input generation
## Testing
diff --git a/scripts/bump-version.mjs b/scripts/bump-version.mjs
new file mode 100644
index 0000000..65c878f
--- /dev/null
+++ b/scripts/bump-version.mjs
@@ -0,0 +1,47 @@
+#!/usr/bin/env node
+/**
+ * Bump the version in the root package.json and sync all workspace packages
+ * to the same version.
+ *
+ * Usage:
+ * node scripts/bump-version.mjs patch
+ * node scripts/bump-version.mjs minor
+ * node scripts/bump-version.mjs major
+ * node scripts/bump-version.mjs 1.4.0
+ * node scripts/bump-version.mjs prerelease --preid=beta
+ *
+ * Writes the new version to stdout (logs go to stderr).
+ */
+
+import { execSync } from "node:child_process";
+import { readFileSync } from "node:fs";
+
+const args = process.argv.slice(2);
+if (!args[0]) {
+ console.error(
+ "Usage: node scripts/bump-version.mjs [--preid=]",
+ );
+ process.exit(1);
+}
+
+const exec = (cmd) =>
+ execSync(cmd, { stdio: ["inherit", "pipe", "inherit"] })
+ .toString()
+ .trim();
+
+const pkgName = JSON.parse(readFileSync("package.json", "utf-8")).name;
+
+const newVersion = exec(
+ `npm version ${args.join(" ")} --no-git-tag-version`,
+).replace(/^v/, "");
+exec(`npm pkg set version=${newVersion} --workspaces`);
+
+// Keep workspace dependency ranges compatible (needed on major bumps)
+const [major, minor] = newVersion.split(".");
+exec(`npm pkg set "dependencies.${pkgName}=^${major}.${minor}.0" --workspaces`);
+
+// Sync package-lock.json so `npm ci` doesn't reject the release PR
+exec("npm install --package-lock-only --ignore-scripts");
+
+console.error(`Bumped root + workspaces to ${newVersion}`);
+console.log(newVersion);
diff --git a/scripts/check-dependency-isolation.mjs b/scripts/check-dependency-isolation.mjs
new file mode 100644
index 0000000..3809224
--- /dev/null
+++ b/scripts/check-dependency-isolation.mjs
@@ -0,0 +1,209 @@
+import { execFileSync } from "node:child_process";
+import {
+ mkdtempSync,
+ mkdirSync,
+ readdirSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+
+const root = resolve(import.meta.dirname, "..");
+const packageJson = JSON.parse(
+ readFileSync(join(root, "package.json"), "utf8"),
+);
+const client = "@modelcontextprotocol/client";
+const server = "@modelcontextprotocol/server";
+
+// App and AppBridge extend the client package's Protocol class, so client is
+// a required peer for every consumer. The server helpers are only needed by
+// server authors, so server stays optional: View-only consumers must not have
+// it installed or bundled.
+for (const role of [client, server]) {
+ if (!packageJson.peerDependencies?.[role]) {
+ throw new Error(`${role} must remain a peer dependency`);
+ }
+}
+if (packageJson.peerDependenciesMeta?.[client]?.optional) {
+ throw new Error(`${client} must be a required peer dependency`);
+}
+if (packageJson.peerDependenciesMeta?.[server]?.optional !== true) {
+ throw new Error(`${server} must be an optional peer dependency`);
+}
+
+/**
+ * Exact version for a synthetic consumer dependency. Read from
+ * devDependencies so the consumers exercise the same SDK version the
+ * repository tests against; the check would silently drift if a range were
+ * allowed here.
+ */
+function exactDevDependency(name) {
+ const version = packageJson.devDependencies?.[name];
+ if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version ?? "")) {
+ throw new Error(
+ `devDependencies["${name}"] must be an exact version, got ${JSON.stringify(version)}`,
+ );
+ }
+ return version;
+}
+
+/** Exact version of a package as installed in this repository's node_modules. */
+function installedVersion(name) {
+ return JSON.parse(
+ readFileSync(join(root, "node_modules", name, "package.json"), "utf8"),
+ ).version;
+}
+
+const temporaryRoot = mkdtempSync(join(tmpdir(), "ext-apps-role-peers-"));
+try {
+ // Minimal environment: nothing inherited from the caller's npm config
+ // (registry overrides, auth tokens, npm_config_* set by an outer `npm run`)
+ // can leak into the synthetic consumers.
+ const npmEnvironment = {
+ PATH: process.env.PATH ?? process.env.Path,
+ HOME: process.env.HOME ?? process.env.USERPROFILE,
+ npm_config_cache: join(temporaryRoot, "npm-cache"),
+ };
+ const packDestination = join(temporaryRoot, "pack");
+ mkdirSync(packDestination);
+ // `npm pack` runs the package's `prepare` script even with --ignore-scripts
+ // (pacote's directory fetcher), and its output can pollute stdout, so do not
+ // rely on `--json`: locate the tarball on disk instead.
+ execFileSync(
+ "npm",
+ ["pack", "--ignore-scripts", "--pack-destination", packDestination],
+ { cwd: root, stdio: "pipe", env: npmEnvironment },
+ );
+ const tarballs = readdirSync(packDestination).filter((name) =>
+ name.endsWith(".tgz"),
+ );
+ if (tarballs.length !== 1) {
+ throw new Error(`expected exactly one tarball, found ${tarballs}`);
+ }
+ const tarball = join(packDestination, tarballs[0]);
+
+ const consumers = [
+ {
+ // View / host author: ext-apps + client (+ react for the hooks entry).
+ name: "app-only",
+ dependencies: {
+ "@types/node": exactDevDependency("@types/node"),
+ "@types/react": installedVersion("@types/react"),
+ [client]: exactDevDependency(client),
+ "@modelcontextprotocol/ext-apps": `file:${tarball}`,
+ react: installedVersion("react"),
+ },
+ absent: server,
+ // server must be neither installed (it is an optional peer) nor bundled.
+ mustNotInstall: true,
+ entry:
+ 'import { App } from "@modelcontextprotocol/ext-apps"; import { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; import { useApp } from "@modelcontextprotocol/ext-apps/react"; console.log(App, AppBridge, useApp);',
+ },
+ {
+ // Server author: ext-apps + server. npm auto-installs client as a
+ // required peer (its types back the shared wire types), but the server
+ // entry must not pull it into a runtime bundle.
+ name: "server-only",
+ dependencies: {
+ "@types/node": exactDevDependency("@types/node"),
+ "@modelcontextprotocol/ext-apps": `file:${tarball}`,
+ [server]: exactDevDependency(server),
+ },
+ absent: client,
+ mustNotInstall: false,
+ entry:
+ 'import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; console.log(registerAppTool);',
+ },
+ ];
+
+ for (const consumer of consumers) {
+ const directory = join(temporaryRoot, consumer.name);
+ mkdirSync(directory);
+ writeFileSync(
+ join(directory, "package.json"),
+ JSON.stringify({
+ private: true,
+ type: "module",
+ dependencies: consumer.dependencies,
+ }),
+ );
+ execFileSync(
+ "npm",
+ [
+ "install",
+ "--ignore-scripts",
+ "--package-lock=false",
+ "--no-audit",
+ "--no-fund",
+ ],
+ { cwd: directory, stdio: "inherit", env: npmEnvironment },
+ );
+
+ if (consumer.mustNotInstall) {
+ const absentPath = join(
+ directory,
+ "node_modules",
+ ...consumer.absent.split("/"),
+ "package.json",
+ );
+ try {
+ readFileSync(absentPath);
+ throw new Error(
+ `${consumer.name} unexpectedly installed ${consumer.absent}`,
+ );
+ } catch (error) {
+ if (error?.code !== "ENOENT") throw error;
+ }
+ }
+
+ writeFileSync(join(directory, "entry.ts"), consumer.entry);
+ writeFileSync(
+ join(directory, "tsconfig.json"),
+ JSON.stringify({
+ compilerOptions: {
+ jsx: "react-jsx",
+ lib: ["ES2020", "DOM"],
+ module: "ESNext",
+ moduleResolution: "bundler",
+ noEmit: true,
+ skipLibCheck: false,
+ strict: true,
+ target: "ES2020",
+ },
+ include: ["entry.ts"],
+ }),
+ );
+ execFileSync(
+ process.execPath,
+ [join(root, "node_modules", "typescript", "bin", "tsc")],
+ { cwd: directory, stdio: "inherit" },
+ );
+
+ const metafile = join(directory, "bundle-meta.json");
+ execFileSync(
+ join(root, "node_modules", ".bin", "esbuild"),
+ [
+ "entry.ts",
+ "--bundle",
+ "--platform=browser",
+ "--outfile=bundle.js",
+ `--metafile=${metafile}`,
+ ],
+ { cwd: directory, stdio: "inherit" },
+ );
+ const bundleInputs = Object.keys(
+ JSON.parse(readFileSync(metafile, "utf8")).inputs,
+ ).join("\n");
+ if (bundleInputs.includes(`/node_modules/${consumer.absent}/`)) {
+ throw new Error(
+ `${consumer.name} unexpectedly bundled ${consumer.absent}`,
+ );
+ }
+ }
+} finally {
+ rmSync(temporaryRoot, { recursive: true, force: true });
+}
+
+console.log("Role peer dependency isolation checks passed.");
diff --git a/scripts/generate-schemas.ts b/scripts/generate-schemas.ts
index 858ab91..0ff9f04 100644
--- a/scripts/generate-schemas.ts
+++ b/scripts/generate-schemas.ts
@@ -8,19 +8,26 @@
*
* ts-to-zod is a powerful tool but has limitations that require post-processing:
*
- * ### 1. Zod Import Path (keep standard `"zod"` for version agnosticism)
+ * ### 1. Zod Import Path (`"zod"` โ `"zod/v4"`)
*
- * ts-to-zod generates `import { z } from "zod"` which works with both
- * Zod v3.25+ and v4. We keep this standard import to support both versions.
+ * ts-to-zod generates `import { z } from "zod"`. We rewrite this to
+ * `import { z } from "zod/v4"` because the generated schemas compose with
+ * schemas imported from `@modelcontextprotocol/core`, which the SDK
+ * constructs via `zod/v4`. Mixing schemas from the v3 and v4 APIs at runtime
+ * fails with errors like `keyValidator._parse is not a function` (v3 internals
+ * calling into v4 objects, or vice versa). The `zod/v4` subpath is exported by
+ * both zod 3.25+ and zod 4.x, so the peerDependency range is preserved.
*
* ### 2. External Type References (`z.any()` โ actual schemas)
*
* **Problem**: ts-to-zod cannot resolve types imported from external packages.
* When it encounters types like `ContentBlock`, `CallToolResult`, `Implementation`,
- * `RequestId`, and `Tool` from `@modelcontextprotocol/sdk`, it generates `z.any()`
- * as a placeholder.
+ * `RequestId`, and `Tool` (imported by spec.types.ts from
+ * `@modelcontextprotocol/client`), it generates `z.any()` as a placeholder.
*
- * **Solution**: Import the schemas from MCP SDK and remove the z.any() placeholders.
+ * **Solution**: Import the corresponding Zod schemas from
+ * `@modelcontextprotocol/core` (the role-neutral package both client and
+ * server re-export) and remove the z.any() placeholders.
*
* ### 3. Index Signatures (`z.record().and()` โ `z.object().passthrough()`)
*
@@ -179,14 +186,16 @@ async function generateJsonSchema() {
* Post-process generated schemas for project compatibility.
*/
function postProcess(content: string): string {
- // 1. Add MCP SDK schema imports (keep standard zod import for v3/v4 compatibility)
+ // 1. Rewrite to zod/v4 and add MCP SDK schema imports.
+ // zod/v4 aligns with the SDK's own zod import โ composing v3 and v4
+ // schema instances throws at parse time. See header comment for details.
const mcpImports = EXTERNAL_TYPE_SCHEMAS.join(",\n ");
content = content.replace(
'import { z } from "zod";',
- `import { z } from "zod";
+ `import { z } from "zod/v4";
import {
${mcpImports},
-} from "@modelcontextprotocol/sdk/types.js";`,
+} from "@modelcontextprotocol/core";`,
);
// 2. Remove z.any() placeholders for external types (now imported from MCP SDK)
@@ -257,7 +266,12 @@ function replaceRecordAndWithPassthrough(content: string): string {
* Post-process generated integration tests.
*/
function postProcessTests(content: string): string {
- // Keep standard zod import for v3/v4 compatibility
+ // Rewrite to zod/v4 so z.infer matches the v4 schemas in schema.ts.
+ content = content.replace(
+ 'import { z } from "zod";',
+ 'import { z } from "zod/v4";',
+ );
+
content = content.replace(
"// Generated by ts-to-zod",
`// Generated by ts-to-zod
diff --git a/scripts/link-self.mjs b/scripts/link-self.mjs
index 41051bf..c1f79cc 100644
--- a/scripts/link-self.mjs
+++ b/scripts/link-self.mjs
@@ -5,10 +5,12 @@
* so examples always type-check against the latest local types.
* See: https://github.com/npm/feedback/discussions/774
*/
-import { cpSync, existsSync } from "fs";
+import { cpSync, existsSync, lstatSync } from "fs";
const target = "node_modules/@modelcontextprotocol/ext-apps";
if (!existsSync(target)) process.exit(0);
+// If target is a symlink (e.g. to the repo root), dist is already current.
+if (lstatSync(target).isSymbolicLink()) process.exit(0);
cpSync("dist", `${target}/dist`, { recursive: true });
cpSync("package.json", `${target}/package.json`);
diff --git a/scripts/setup-bun.mjs b/scripts/setup-bun.mjs
deleted file mode 100644
index 3314cac..0000000
--- a/scripts/setup-bun.mjs
+++ /dev/null
@@ -1,377 +0,0 @@
-#!/usr/bin/env node
-// Immediate log to verify script execution
-console.log("[setup-bun] Script loaded");
-
-/**
- * Postinstall script to set up bun from platform-specific optional dependencies.
- * Handles Windows ARM64 by downloading x64-baseline via emulation.
- */
-import {
- existsSync,
- mkdirSync,
- symlinkSync,
- unlinkSync,
- copyFileSync,
- chmodSync,
- writeFileSync,
- statSync,
-} from "fs";
-import { join, dirname } from "path";
-import { spawnSync } from "child_process";
-import { fileURLToPath } from "url";
-import { get } from "https";
-import { createGunzip } from "zlib";
-
-const __dirname = dirname(fileURLToPath(import.meta.url));
-const projectRoot = join(__dirname, "..");
-const nodeModules = join(projectRoot, "node_modules");
-const binDir = join(nodeModules, ".bin");
-
-const os = process.platform;
-const arch = process.arch;
-const isWindows = os === "win32";
-const bunExe = isWindows ? "bun.exe" : "bun";
-
-// Detect libc type on Linux (glibc vs musl)
-function detectLibc() {
- if (os !== "linux") return null;
-
- // Check for musl-specific loader
- const muslLoaders = [
- `/lib/ld-musl-${arch === "arm64" ? "aarch64" : "x86_64"}.so.1`,
- "/lib/ld-musl-x86_64.so.1",
- "/lib/ld-musl-aarch64.so.1",
- ];
-
- for (const loader of muslLoaders) {
- if (existsSync(loader)) {
- console.log(` Detected musl libc (found ${loader})`);
- return "musl";
- }
- }
-
- // Default to glibc on Linux
- console.log(" Detected glibc (no musl loader found)");
- return "glibc";
-}
-
-// Platform to package mapping (matches @oven/bun-* package names)
-// For Linux, separate glibc and musl packages
-const platformPackages = {
- darwin: {
- arm64: ["bun-darwin-aarch64"],
- x64: ["bun-darwin-x64", "bun-darwin-x64-baseline"],
- },
- linux: {
- arm64: {
- glibc: ["bun-linux-aarch64"],
- musl: ["bun-linux-aarch64-musl"],
- },
- x64: {
- glibc: ["bun-linux-x64", "bun-linux-x64-baseline"],
- musl: ["bun-linux-x64-musl", "bun-linux-x64-musl-baseline"],
- },
- },
- win32: {
- x64: ["bun-windows-x64", "bun-windows-x64-baseline"],
- arm64: ["bun-windows-x64-baseline"], // x64 runs via emulation on ARM64
- },
-};
-
-function findBunBinary() {
- let packages = platformPackages[os]?.[arch];
-
- // For Linux, select packages based on libc type
- if (os === "linux" && packages && typeof packages === "object") {
- const libc = detectLibc();
- packages = packages[libc] || [];
- }
-
- packages = packages || [];
- console.log(
- `Looking for bun packages: ${packages.join(", ") || "(none for this platform)"}`,
- );
-
- for (const pkg of packages) {
- const binPath = join(nodeModules, "@oven", pkg, "bin", bunExe);
- console.log(` Checking: ${binPath}`);
- if (existsSync(binPath)) {
- console.log(` Found bun at: ${binPath}`);
- return binPath;
- } else {
- console.log(` Not found`);
- }
- }
-
- return null;
-}
-
-async function downloadBunForWindowsArm64() {
- // Windows ARM64 can run x64 binaries via emulation
- const pkg = "bun-windows-x64-baseline";
- const version = "1.2.21";
- const url = `https://registry.npmjs.org/@oven/${pkg}/-/${pkg}-${version}.tgz`;
- const destDir = join(nodeModules, "@oven", pkg);
-
- console.log(`Downloading ${pkg} for Windows ARM64 emulation...`);
-
- return new Promise((resolve, reject) => {
- get(url, (response) => {
- if (response.statusCode === 302 || response.statusCode === 301) {
- get(response.headers.location, handleResponse).on("error", reject);
- } else {
- handleResponse(response);
- }
-
- function handleResponse(res) {
- if (res.statusCode !== 200) {
- reject(new Error(`Failed to download: ${res.statusCode}`));
- return;
- }
-
- const chunks = [];
- const gunzip = createGunzip();
-
- res.pipe(gunzip);
-
- gunzip.on("data", (chunk) => chunks.push(chunk));
- gunzip.on("end", () => {
- try {
- extractTar(Buffer.concat(chunks), destDir);
- const binPath = join(destDir, "bin", bunExe);
- if (existsSync(binPath)) {
- resolve(binPath);
- } else {
- reject(new Error("Binary not found after extraction"));
- }
- } catch (err) {
- reject(err);
- }
- });
- gunzip.on("error", reject);
- }
- }).on("error", reject);
- });
-}
-
-function extractTar(buffer, destDir) {
- // Simple tar extraction (512-byte blocks)
- let offset = 0;
- while (offset < buffer.length) {
- const name = buffer
- .toString("utf-8", offset, offset + 100)
- .replace(/\0.*$/, "")
- .replace("package/", "");
- const size = parseInt(
- buffer.toString("utf-8", offset + 124, offset + 136).trim(),
- 8,
- );
-
- offset += 512;
-
- if (!isNaN(size) && size > 0 && name) {
- const filePath = join(destDir, name);
- const fileDir = dirname(filePath);
- if (!existsSync(fileDir)) {
- mkdirSync(fileDir, { recursive: true });
- }
- const content = buffer.subarray(offset, offset + size);
- writeFileSync(filePath, content);
-
- // Make executable
- if (name.endsWith(bunExe) || name === "bin/bun") {
- try {
- chmodSync(filePath, 0o755);
- } catch {}
- }
-
- offset += Math.ceil(size / 512) * 512;
- }
- }
-}
-
-function copyFileWithRetry(source, dest, maxRetries = 3, delay = 100) {
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
- try {
- copyFileSync(source, dest);
- return true;
- } catch (err) {
- if (attempt === maxRetries) {
- throw err;
- }
- // Wait a bit before retrying (exponential backoff)
- const waitTime = delay * Math.pow(2, attempt - 1);
- const start = Date.now();
- while (Date.now() - start < waitTime) {
- // Busy wait (simple delay without external dependencies)
- }
- }
- }
- return false;
-}
-
-function setupBinLink(bunPath) {
- if (!existsSync(binDir)) {
- mkdirSync(binDir, { recursive: true });
- }
-
- const bunLink = join(binDir, bunExe);
- const bunxLink = join(binDir, isWindows ? "bunx.exe" : "bunx");
-
- // Check if files already exist and are valid (same size as source)
- // This can help avoid unnecessary copy operations that might fail
- let needsCopy = true;
- if (existsSync(bunLink) && existsSync(bunxLink)) {
- try {
- const sourceStat = statSync(bunPath);
- const linkStat = statSync(bunLink);
- if (sourceStat.size === linkStat.size) {
- console.log(
- "Bun binaries already exist and appear valid, skipping copy",
- );
- needsCopy = false;
- }
- } catch {
- // If stat fails, proceed with copy
- needsCopy = true;
- }
- }
-
- if (needsCopy) {
- // Remove existing links
- for (const link of [bunLink, bunxLink]) {
- try {
- unlinkSync(link);
- } catch {}
- }
- } else {
- console.log(`Bun linked to: ${bunLink}`);
- return;
- }
-
- if (isWindows) {
- // On Windows, copy the binary (symlinks may not work without admin)
- // Use retry logic to handle file locking issues
- try {
- copyFileWithRetry(bunPath, bunLink);
- copyFileWithRetry(bunPath, bunxLink);
- } catch (err) {
- // If copy fails, try using Windows copy command as fallback
- console.log(`Copy failed, trying Windows copy command: ${err.message}`);
- try {
- if (isWindows) {
- // Use cmd /c copy for Windows with proper path quoting
- // Paths with spaces need to be quoted, and we need to handle backslashes
- const sourceQuoted = `"${bunPath}"`;
- const destQuoted = `"${bunLink}"`;
- const destxQuoted = `"${bunxLink}"`;
-
- // Try copying with a small delay between attempts
- const result1 = spawnSync(
- "cmd.exe",
- ["/c", "copy", "/Y", sourceQuoted, destQuoted],
- { shell: false, stdio: "pipe" },
- );
-
- // Small delay before second copy
- const start = Date.now();
- while (Date.now() - start < 50) {}
-
- const result2 = spawnSync(
- "cmd.exe",
- ["/c", "copy", "/Y", sourceQuoted, destxQuoted],
- { shell: false, stdio: "pipe" },
- );
-
- if (result1.status !== 0) {
- const errorMsg =
- result1.stderr?.toString() ||
- result1.stdout?.toString() ||
- "Unknown error";
- throw new Error(
- `Windows copy command failed for bun.exe: ${errorMsg}`,
- );
- }
- if (result2.status !== 0) {
- const errorMsg =
- result2.stderr?.toString() ||
- result2.stdout?.toString() ||
- "Unknown error";
- throw new Error(
- `Windows copy command failed for bunx.exe: ${errorMsg}`,
- );
- }
-
- // Verify files were created
- if (!existsSync(bunLink) || !existsSync(bunxLink)) {
- throw new Error("Files were not created after copy command");
- }
- } else {
- // Fallback to original copyFileSync if not Windows
- copyFileSync(bunPath, bunLink);
- copyFileSync(bunPath, bunxLink);
- }
- } catch (fallbackErr) {
- // If all copy methods fail, log the error but don't throw
- // The script will exit gracefully and bun can be installed manually
- console.error(
- `All copy methods failed. Bun setup incomplete: ${fallbackErr.message}`,
- );
- console.error(
- "You may need to install bun manually or run this script with appropriate permissions.",
- );
- throw fallbackErr;
- }
- }
- } else {
- // On Unix, use symlinks
- symlinkSync(bunPath, bunLink);
- symlinkSync(bunPath, bunxLink);
- }
-
- console.log(`Bun linked to: ${bunLink}`);
-}
-
-// Force immediate output
-process.stdout.write("[setup-bun] Script starting...\n");
-
-async function main() {
- process.stdout.write(`[setup-bun] Setting up bun for ${os} ${arch}...\n`);
- process.stdout.write(`[setup-bun] Project root: ${projectRoot}\n`);
- process.stdout.write(`[setup-bun] Node modules: ${nodeModules}\n`);
-
- let bunPath = findBunBinary();
-
- if (!bunPath && os === "win32" && arch === "arm64") {
- try {
- bunPath = await downloadBunForWindowsArm64();
- } catch (err) {
- console.error("Failed to download bun for Windows ARM64:", err.message);
- }
- }
-
- if (!bunPath) {
- console.log("No bun binary found in optional dependencies.");
- console.log("Bun will need to be installed separately.");
- console.log("See: https://bun.sh/docs/installation");
- process.exit(0); // Don't fail the install
- }
-
- try {
- setupBinLink(bunPath);
-
- // Verify installation
- const result = spawnSync(bunPath, ["--version"], { encoding: "utf-8" });
- if (result.status === 0) {
- console.log(`Bun ${result.stdout.trim()} installed successfully!`);
- }
- } catch (err) {
- console.error("Failed to set up bun:", err.message);
- process.exit(0); // Don't fail the install
- }
-}
-
-main().catch((err) => {
- console.error(err);
- process.exit(0); // Don't fail the install
-});
diff --git a/scripts/typedoc-plugin-base-sdk-links.mjs b/scripts/typedoc-plugin-base-sdk-links.mjs
new file mode 100644
index 0000000..6098918
--- /dev/null
+++ b/scripts/typedoc-plugin-base-sdk-links.mjs
@@ -0,0 +1,16 @@
+/**
+ * Resolve module-local links inherited from the base MCP SDK declarations.
+ *
+ * TypeDoc's externalSymbolLinkMappings handles package-qualified symbols, but
+ * the base SDK writes this particular reference as `index.inputRequired`.
+ */
+
+const BASE_SDK_DOCS = "https://ts.sdk.modelcontextprotocol.io/v2/";
+
+/** @param {import("typedoc").Application} app */
+export function load(app) {
+ app.converter.addUnknownSymbolResolver((ref) => {
+ const name = ref.symbolReference?.path?.map((part) => part.path).join(".");
+ if (name === "index.inputRequired") return BASE_SDK_DOCS;
+ });
+}
diff --git a/scripts/typedoc-plugin-mcpstyle.mjs b/scripts/typedoc-plugin-mcpstyle.mjs
new file mode 100644
index 0000000..aea65bc
--- /dev/null
+++ b/scripts/typedoc-plugin-mcpstyle.mjs
@@ -0,0 +1,67 @@
+/**
+ * TypeDoc plugin that applies MCP-specific styling tweaks.
+ *
+ * - Moves custom.css to load last so overrides win the cascade
+ * - Replaces breadcrumbs with the document group name (e.g. "Security")
+ * - Marks the current sidebar nav link for CSS highlighting
+ * - Rewrites the README logo to a pair of theme-tagged s so
+ * the logo follows the TypeDoc theme switcher (data-theme), not just the
+ * OS prefers-color-scheme media query
+ */
+
+import { Renderer } from "typedoc";
+
+/**
+ * TypeDoc plugin entry point.
+ * @param {import('typedoc').Application} app
+ */
+export function load(app) {
+ app.renderer.on(Renderer.EVENT_END_PAGE, (page) => {
+ if (!page.contents) return;
+
+ // Move custom.css to load after all theme stylesheets so overrides win the cascade
+ const customCssLink = page.contents.match(
+ / /,
+ );
+ if (customCssLink) {
+ page.contents = page.contents.replace(customCssLink[0], "");
+ page.contents = page.contents.replace(
+ "",
+ customCssLink[0] + "\n",
+ );
+ }
+
+ // For document pages, replace the breadcrumb with the group name
+ // (e.g. "Security", "Getting Started"). The page title is in the H1.
+ if (page.model?.isDocument?.() && page.model.frontmatter?.group) {
+ const group = String(page.model.frontmatter.group);
+ page.contents = page.contents.replace(
+ /]*>.*?<\/ul>/,
+ ``,
+ );
+ }
+
+ // The README logo uses with prefers-color-scheme, which follows
+ // the OS setting and ignores the TypeDoc theme switcher (data-theme attr).
+ // Rewrite it as two tags โ CSS in mcp-theme.css picks the correct
+ // one based on both data-theme and prefers-color-scheme (for "OS" mode).
+ page.contents = page.contents.replace(
+ /\s*\s*\s* ]*)>\s*<\/picture>/,
+ (_, darkSrc, lightSrc, imgAttrs) =>
+ ` ` +
+ ` `,
+ );
+
+ // Inject script to mark the current sidebar nav link with a "current" class.
+ // TypeDoc does not natively add this class for document pages.
+ // The sidebar is populated asynchronously from compressed navigation data,
+ // so we use a MutationObserver to detect when links appear.
+ // Pathname comparison strips trailing slashes and .html extensions to handle
+ // servers with clean-URL mode (e.g. `serve` drops .html).
+ const currentNavScript = ``;
+ page.contents = page.contents.replace(
+ "