diff --git a/.gitattributes b/.gitattributes index cf968721..309467bb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,5 @@ +# Force LF line endings for all text files (for consistent prettier formatting) +* text=auto eol=lf + package-lock.json linguist-generated=true +src/generated/** linguist-generated=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd795a94..be0db8ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,26 +11,43 @@ permissions: jobs: build: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + name: Linux x64 + - os: ubuntu-24.04-arm + name: Linux ARM64 + - os: windows-latest + name: Windows x64 + - os: windows-11-arm + name: Windows ARM64 + - os: macos-latest + name: macOS ARM64 + + name: Build (${{ matrix.name }}) + runs-on: ${{ matrix.os }} + steps: - uses: actions/checkout@v4 - name: Verify no private URLs in package-lock.json + shell: bash run: '! grep -E "\"resolved\": \"https?://" package-lock.json | grep -v registry.npmjs.org' - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - uses: actions/setup-node@v4 with: node-version: "20" - run: npm install - - run: npm run build:all + - run: npm run build + + - run: npm run examples:build - name: Verify generated schemas are up-to-date + shell: bash run: | npm run generate:schemas git diff --exit-code src/generated/ || (echo "Generated schemas are out of date. Run 'npm run generate:schemas' and commit." && exit 1) @@ -67,3 +84,67 @@ jobs: name: test-results path: test-results/ retention-days: 7 + + # Test build in Windows WSL (Ubuntu) + build-wsl: + name: Build (Windows WSL) + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - uses: Vampire/setup-wsl@v5 + with: + distribution: Ubuntu-24.04 + + - name: Install Node.js in WSL + shell: wsl-bash {0} + run: | + sudo apt-get update + curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - + sudo apt-get install -y nodejs + + - name: Build and test in WSL + shell: wsl-bash {0} + run: | + npm install + npm run build + npm run examples:build + npm test + npm run prettier + + # Test that the package can be installed from git (triggers prepare script) + test-git-install: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + name: Linux x64 + - os: ubuntu-24.04-arm + name: Linux ARM64 + - os: windows-latest + name: Windows x64 + - os: windows-11-arm + name: Windows ARM64 + - os: macos-latest + name: macOS ARM64 + + name: Test git install (${{ matrix.name }}) + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Create test project and install from git + shell: bash + run: | + mkdir test-project + cd test-project + npm init -y + # Install from the PR branch + npm install "git+https://github.com/${{ github.repository }}#${{ github.head_ref || github.ref_name }}" + # Verify the package is usable (ESM import) + node --input-type=module -e "import { App } from '@modelcontextprotocol/ext-apps'; console.log('Import successful:', typeof App)" diff --git a/.husky/pre-commit b/.husky/pre-commit index 6cc0dca3..70003976 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +1,10 @@ +# Verify no private registry URLs in package-lock.json +if grep -E '"resolved": "https?://' package-lock.json | grep -v registry.npmjs.org > /dev/null; then + echo "ERROR: package-lock.json contains non-npmjs.org URLs" + echo "Run: docker run --rm -i -v \$PWD:/src -w /src node:latest npm i --registry=https://registry.npmjs.org/" + exit 1 +fi + npm run build:all npm run prettier:fix diff --git a/README.md b/README.md index d6d542ed..d7cb04fe 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ The [`examples/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/exa To run all examples together: -``` +```bash npm install npm run examples:start ``` diff --git a/examples/basic-host/package.json b/examples/basic-host/package.json index 7615eb15..10d9e1fc 100644 --- a/examples/basic-host/package.json +++ b/examples/basic-host/package.json @@ -4,11 +4,11 @@ "version": "1.0.0", "type": "module", "scripts": { - "build": "concurrently 'INPUT=index.html vite build' 'INPUT=sandbox.html vite build'", - "watch": "concurrently 'INPUT=index.html vite build --watch' 'INPUT=sandbox.html vite build --watch'", + "build": "concurrently \"cross-env INPUT=index.html vite build\" \"cross-env INPUT=sandbox.html vite build\"", + "watch": "concurrently \"cross-env INPUT=index.html vite build --watch\" \"cross-env INPUT=sandbox.html vite build --watch\"", "serve": "bun serve.ts", - "start": "NODE_ENV=development npm run build && npm run serve", - "dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve'" + "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\"" }, "dependencies": { "@modelcontextprotocol/ext-apps": "../..", @@ -23,7 +23,6 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^4.3.4", - "bun": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", diff --git a/examples/basic-server-react/package.json b/examples/basic-server-react/package.json index 35aa9f35..a4b642c0 100644 --- a/examples/basic-server-react/package.json +++ b/examples/basic-server-react/package.json @@ -4,11 +4,11 @@ "private": true, "type": "module", "scripts": { - "build": "INPUT=mcp-app.html vite build", - "watch": "INPUT=mcp-app.html vite build --watch", + "build": "cross-env INPUT=mcp-app.html vite build", + "watch": "cross-env INPUT=mcp-app.html vite build --watch", "serve": "bun server.ts", - "start": "NODE_ENV=development npm run build && npm run serve", - "dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve'" + "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'" }, "dependencies": { "@modelcontextprotocol/ext-apps": "../..", @@ -24,7 +24,6 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^4.3.4", - "bun": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", diff --git a/examples/basic-server-react/server.ts b/examples/basic-server-react/server.ts index 16b311f3..374e7e1c 100644 --- a/examples/basic-server-react/server.ts +++ b/examples/basic-server-react/server.ts @@ -3,7 +3,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; import fs from "node:fs/promises"; import path from "node:path"; -import { RESOURCE_MIME_TYPE, type McpUiToolMeta } from "../../dist/src/app"; +import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, type McpUiToolMeta } from "@modelcontextprotocol/ext-apps/server"; import { startServer } from "../shared/server-utils.js"; const DIST_DIR = path.join(import.meta.dirname, "dist"); @@ -22,7 +22,7 @@ function createServer(): McpServer { // MCP Apps require two-part registration: a tool (what the LLM calls) and a // resource (the UI it renders). The `_meta` field on the tool links to the // resource URI, telling hosts which UI to display when the tool executes. - server.registerTool( + registerAppTool(server, "get-time", { title: "Get Time", @@ -38,7 +38,7 @@ function createServer(): McpServer { }, ); - server.registerResource( + registerAppResource(server, RESOURCE_URI, RESOURCE_URI, { mimeType: RESOURCE_MIME_TYPE }, diff --git a/examples/basic-server-vanillajs/README.md b/examples/basic-server-vanillajs/README.md index 8d227266..0d7cb619 100644 --- a/examples/basic-server-vanillajs/README.md +++ b/examples/basic-server-vanillajs/README.md @@ -52,5 +52,5 @@ button.addEventListener("click", () => { }); // Connect to host -app.connect(new PostMessageTransport(window.parent)); +app.connect(); ``` diff --git a/examples/basic-server-vanillajs/package.json b/examples/basic-server-vanillajs/package.json index a44e65d1..1203dde9 100644 --- a/examples/basic-server-vanillajs/package.json +++ b/examples/basic-server-vanillajs/package.json @@ -4,11 +4,11 @@ "private": true, "type": "module", "scripts": { - "build": "INPUT=mcp-app.html vite build", - "watch": "INPUT=mcp-app.html vite build --watch", + "build": "cross-env INPUT=mcp-app.html vite build", + "watch": "cross-env INPUT=mcp-app.html vite build --watch", "serve": "bun server.ts", - "start": "NODE_ENV=development npm run build && npm run serve", - "dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve'" + "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'" }, "dependencies": { "@modelcontextprotocol/ext-apps": "../..", @@ -19,7 +19,6 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.0", "@types/node": "^22.0.0", - "bun": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", diff --git a/examples/basic-server-vanillajs/server.ts b/examples/basic-server-vanillajs/server.ts index c13a5f45..fdc1e10f 100644 --- a/examples/basic-server-vanillajs/server.ts +++ b/examples/basic-server-vanillajs/server.ts @@ -3,7 +3,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; import fs from "node:fs/promises"; import path from "node:path"; -import { RESOURCE_MIME_TYPE, type McpUiToolMeta } from "../../dist/src/app"; +import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, type McpUiToolMeta } from "@modelcontextprotocol/ext-apps/server"; import { startServer } from "../shared/server-utils.js"; const DIST_DIR = path.join(import.meta.dirname, "dist"); @@ -22,7 +22,7 @@ function createServer(): McpServer { // MCP Apps require two-part registration: a tool (what the LLM calls) and a // resource (the UI it renders). The `_meta` field on the tool links to the // resource URI, telling hosts which UI to display when the tool executes. - server.registerTool( + registerAppTool(server, "get-time", { title: "Get Time", @@ -38,7 +38,7 @@ function createServer(): McpServer { }, ); - server.registerResource( + registerAppResource(server, RESOURCE_URI, RESOURCE_URI, { mimeType: RESOURCE_MIME_TYPE }, diff --git a/examples/basic-server-vanillajs/src/mcp-app.ts b/examples/basic-server-vanillajs/src/mcp-app.ts index 7bfa6d69..54442520 100644 --- a/examples/basic-server-vanillajs/src/mcp-app.ts +++ b/examples/basic-server-vanillajs/src/mcp-app.ts @@ -1,7 +1,7 @@ /** * @file App that demonstrates a few features using MCP Apps SDK with vanilla JS. */ -import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps"; +import { App } from "@modelcontextprotocol/ext-apps"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import "./global.css"; import "./mcp-app.css"; @@ -98,4 +98,4 @@ openLinkBtn.addEventListener("click", async () => { // Connect to host -app.connect(new PostMessageTransport(window.parent)); +app.connect(); diff --git a/examples/budget-allocator-server/package.json b/examples/budget-allocator-server/package.json index 80cacc9e..bffa9e99 100644 --- a/examples/budget-allocator-server/package.json +++ b/examples/budget-allocator-server/package.json @@ -4,14 +4,14 @@ "private": true, "type": "module", "scripts": { - "build": "INPUT=mcp-app.html vite build", - "watch": "INPUT=mcp-app.html vite build --watch", + "build": "cross-env INPUT=mcp-app.html vite build", + "watch": "cross-env INPUT=mcp-app.html vite build --watch", "serve:http": "bun server.ts", "serve:stdio": "bun server.ts --stdio", "start": "npm run start:http", - "start:http": "NODE_ENV=development npm run build && npm run serve:http", - "start:stdio": "NODE_ENV=development npm run build && npm run serve:stdio", - "dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve: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", + "dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve:http'" }, "dependencies": { "@modelcontextprotocol/ext-apps": "../..", @@ -23,7 +23,6 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.0", "@types/node": "^22.0.0", - "bun": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", diff --git a/examples/budget-allocator-server/server.ts b/examples/budget-allocator-server/server.ts index 03aa582c..4f789e2f 100755 --- a/examples/budget-allocator-server/server.ts +++ b/examples/budget-allocator-server/server.ts @@ -13,7 +13,12 @@ import type { import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; -import { RESOURCE_MIME_TYPE, type McpUiToolMeta } from "../../dist/src/app"; +import { + registerAppTool, + registerAppResource, + RESOURCE_MIME_TYPE, + type McpUiToolMeta, +} from "@modelcontextprotocol/ext-apps/server"; import { startServer } from "../shared/server-utils.js"; const DIST_DIR = path.join(import.meta.dirname, "dist"); @@ -235,7 +240,8 @@ function createServer(): McpServer { version: "1.0.0", }); - server.registerTool( + registerAppTool( + server, "get-budget-data", { title: "Get Budget Data", @@ -277,7 +283,8 @@ function createServer(): McpServer { }, ); - server.registerResource( + registerAppResource( + server, resourceUri, resourceUri, { diff --git a/examples/budget-allocator-server/src/mcp-app.ts b/examples/budget-allocator-server/src/mcp-app.ts index 723dc060..9cddc8ad 100644 --- a/examples/budget-allocator-server/src/mcp-app.ts +++ b/examples/budget-allocator-server/src/mcp-app.ts @@ -1,7 +1,7 @@ /** * Budget Allocator App - Interactive budget allocation with real-time visualization */ -import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps"; +import { App } from "@modelcontextprotocol/ext-apps"; import { Chart, registerables } from "chart.js"; import "./global.css"; import "./mcp-app.css"; @@ -631,4 +631,4 @@ window }); // Connect to host -app.connect(new PostMessageTransport(window.parent)); +app.connect(); diff --git a/examples/cohort-heatmap-server/package.json b/examples/cohort-heatmap-server/package.json index 32e6cbf1..cd4db959 100644 --- a/examples/cohort-heatmap-server/package.json +++ b/examples/cohort-heatmap-server/package.json @@ -4,14 +4,14 @@ "private": true, "type": "module", "scripts": { - "build": "INPUT=mcp-app.html vite build", - "watch": "INPUT=mcp-app.html vite build --watch", + "build": "cross-env INPUT=mcp-app.html vite build", + "watch": "cross-env INPUT=mcp-app.html vite build --watch", "serve:http": "bun server.ts", "serve:stdio": "bun server.ts --stdio", "start": "npm run start:http", - "start:http": "NODE_ENV=development npm run build && npm run serve:http", - "start:stdio": "NODE_ENV=development npm run build && npm run serve:stdio", - "dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve: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", + "dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve:http'" }, "dependencies": { "@modelcontextprotocol/ext-apps": "../..", @@ -27,7 +27,6 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^4.3.4", - "bun": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", diff --git a/examples/cohort-heatmap-server/server.ts b/examples/cohort-heatmap-server/server.ts index cea3408c..1f7d7413 100644 --- a/examples/cohort-heatmap-server/server.ts +++ b/examples/cohort-heatmap-server/server.ts @@ -4,7 +4,12 @@ import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; -import { RESOURCE_MIME_TYPE, type McpUiToolMeta } from "../../dist/src/app"; +import { + registerAppTool, + registerAppResource, + RESOURCE_MIME_TYPE, + type McpUiToolMeta, +} from "@modelcontextprotocol/ext-apps/server"; import { startServer } from "../shared/server-utils.js"; const DIST_DIR = path.join(import.meta.dirname, "dist"); @@ -156,7 +161,8 @@ function createServer(): McpServer { // Register tool and resource const resourceUri = "ui://get-cohort-data/mcp-app.html"; - server.registerTool( + registerAppTool( + server, "get-cohort-data", { title: "Get Cohort Retention Data", @@ -179,7 +185,8 @@ function createServer(): McpServer { }, ); - server.registerResource( + registerAppResource( + server, resourceUri, resourceUri, { mimeType: RESOURCE_MIME_TYPE }, diff --git a/examples/customer-segmentation-server/package.json b/examples/customer-segmentation-server/package.json index 23083964..dfc96f14 100644 --- a/examples/customer-segmentation-server/package.json +++ b/examples/customer-segmentation-server/package.json @@ -4,14 +4,14 @@ "private": true, "type": "module", "scripts": { - "build": "INPUT=mcp-app.html vite build", - "watch": "INPUT=mcp-app.html vite build --watch", + "build": "cross-env INPUT=mcp-app.html vite build", + "watch": "cross-env INPUT=mcp-app.html vite build --watch", "serve:http": "bun server.ts", "serve:stdio": "bun server.ts --stdio", "start": "npm run start:http", - "start:http": "NODE_ENV=development npm run build && npm run serve:http", - "start:stdio": "NODE_ENV=development npm run build && npm run serve:stdio", - "dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve: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", + "dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve:http'" }, "dependencies": { "@modelcontextprotocol/ext-apps": "../..", @@ -23,7 +23,6 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.0", "@types/node": "^22.0.0", - "bun": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", diff --git a/examples/customer-segmentation-server/server.ts b/examples/customer-segmentation-server/server.ts index e45699b8..f625f4a2 100644 --- a/examples/customer-segmentation-server/server.ts +++ b/examples/customer-segmentation-server/server.ts @@ -7,7 +7,12 @@ import type { import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; -import { RESOURCE_MIME_TYPE, type McpUiToolMeta } from "../../dist/src/app"; +import { + registerAppTool, + registerAppResource, + RESOURCE_MIME_TYPE, + type McpUiToolMeta, +} from "@modelcontextprotocol/ext-apps/server"; import { startServer } from "../shared/server-utils.js"; import { generateCustomers, @@ -65,7 +70,8 @@ function createServer(): McpServer { { const resourceUri = "ui://customer-segmentation/mcp-app.html"; - server.registerTool( + registerAppTool( + server, "get-customer-data", { title: "Get Customer Data", @@ -83,7 +89,8 @@ function createServer(): McpServer { }, ); - server.registerResource( + registerAppResource( + server, resourceUri, resourceUri, { diff --git a/examples/customer-segmentation-server/src/mcp-app.ts b/examples/customer-segmentation-server/src/mcp-app.ts index 77e348de..39221584 100644 --- a/examples/customer-segmentation-server/src/mcp-app.ts +++ b/examples/customer-segmentation-server/src/mcp-app.ts @@ -463,7 +463,7 @@ app.onhostcontextchanged = (params) => { } }; -app.connect(new PostMessageTransport(window.parent)).then(() => { +app.connect().then(() => { // Apply initial host context after connection const ctx = app.getHostContext(); if (ctx?.theme) { diff --git a/examples/qr-server/widget.html b/examples/qr-server/widget.html index e2ff4cb0..9275ff68 100644 --- a/examples/qr-server/widget.html +++ b/examples/qr-server/widget.html @@ -47,7 +47,7 @@ } }; - await app.connect(new PostMessageTransport(window.parent)); + await app.connect(); diff --git a/examples/scenario-modeler-server/package.json b/examples/scenario-modeler-server/package.json index a1e073f3..2eb11a25 100644 --- a/examples/scenario-modeler-server/package.json +++ b/examples/scenario-modeler-server/package.json @@ -4,14 +4,14 @@ "private": true, "type": "module", "scripts": { - "build": "INPUT=mcp-app.html vite build", - "watch": "INPUT=mcp-app.html vite build --watch", + "build": "cross-env INPUT=mcp-app.html vite build", + "watch": "cross-env INPUT=mcp-app.html vite build --watch", "serve:http": "bun server.ts", "serve:stdio": "bun server.ts --stdio", "start": "npm run start:http", - "start:http": "NODE_ENV=development npm run build && npm run serve:http", - "start:stdio": "NODE_ENV=development npm run build && npm run serve:stdio", - "dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve: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", + "dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve:http'" }, "dependencies": { "@modelcontextprotocol/ext-apps": "../..", @@ -28,7 +28,6 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^4.3.4", - "bun": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", diff --git a/examples/scenario-modeler-server/server.ts b/examples/scenario-modeler-server/server.ts index 884842eb..658f4a94 100644 --- a/examples/scenario-modeler-server/server.ts +++ b/examples/scenario-modeler-server/server.ts @@ -7,7 +7,12 @@ import type { import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; -import { RESOURCE_MIME_TYPE, type McpUiToolMeta } from "../../dist/src/app"; +import { + registerAppTool, + registerAppResource, + RESOURCE_MIME_TYPE, + type McpUiToolMeta, +} from "@modelcontextprotocol/ext-apps/server"; import { startServer } from "../shared/server-utils.js"; const DIST_DIR = path.join(import.meta.dirname, "dist"); @@ -256,7 +261,8 @@ function createServer(): McpServer { { const resourceUri = "ui://scenario-modeler/mcp-app.html"; - server.registerTool( + registerAppTool( + server, "get-scenario-data", { title: "Get Scenario Data", @@ -288,7 +294,8 @@ function createServer(): McpServer { }, ); - server.registerResource( + registerAppResource( + server, resourceUri, resourceUri, { mimeType: RESOURCE_MIME_TYPE, description: "SaaS Scenario Modeler UI" }, diff --git a/examples/system-monitor-server/package.json b/examples/system-monitor-server/package.json index 21455742..082042e8 100644 --- a/examples/system-monitor-server/package.json +++ b/examples/system-monitor-server/package.json @@ -4,14 +4,14 @@ "private": true, "type": "module", "scripts": { - "build": "INPUT=mcp-app.html vite build", - "watch": "INPUT=mcp-app.html vite build --watch", + "build": "cross-env INPUT=mcp-app.html vite build", + "watch": "cross-env INPUT=mcp-app.html vite build --watch", "serve:http": "bun server.ts", "serve:stdio": "bun server.ts --stdio", "start": "npm run start:http", - "start:http": "NODE_ENV=development npm run build && npm run serve:http", - "start:stdio": "NODE_ENV=development npm run build && npm run serve:stdio", - "dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve: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", + "dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve:http'" }, "dependencies": { "@modelcontextprotocol/ext-apps": "../..", @@ -24,7 +24,6 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.0", "@types/node": "^22.0.0", - "bun": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", diff --git a/examples/system-monitor-server/server.ts b/examples/system-monitor-server/server.ts index c709848d..97a382d9 100644 --- a/examples/system-monitor-server/server.ts +++ b/examples/system-monitor-server/server.ts @@ -9,7 +9,12 @@ import os from "node:os"; import path from "node:path"; import si from "systeminformation"; import { z } from "zod"; -import { RESOURCE_MIME_TYPE, type McpUiToolMeta } from "../../dist/src/app"; +import { + registerAppTool, + registerAppResource, + RESOURCE_MIME_TYPE, + type McpUiToolMeta, +} from "@modelcontextprotocol/ext-apps/server"; import { startServer } from "../shared/server-utils.js"; // Schemas - types are derived from these using z.infer @@ -141,7 +146,8 @@ function createServer(): McpServer { }; } - server.registerTool( + registerAppTool( + server, "get-system-stats", { title: "Get System Stats", @@ -174,7 +180,8 @@ function createServer(): McpServer { getStats, ); - server.registerResource( + registerAppResource( + server, resourceUri, resourceUri, { mimeType: RESOURCE_MIME_TYPE, description: "System Monitor UI" }, diff --git a/examples/system-monitor-server/src/mcp-app.ts b/examples/system-monitor-server/src/mcp-app.ts index f857eefb..1dc7ff2f 100644 --- a/examples/system-monitor-server/src/mcp-app.ts +++ b/examples/system-monitor-server/src/mcp-app.ts @@ -1,7 +1,7 @@ /** * @file System Monitor App - displays real-time OS metrics with Chart.js */ -import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps"; +import { App } from "@modelcontextprotocol/ext-apps"; import { Chart, registerables } from "chart.js"; import "./global.css"; import "./mcp-app.css"; @@ -360,7 +360,7 @@ window // Register handlers and connect app.onerror = log.error; -app.connect(new PostMessageTransport(window.parent)); +app.connect(); // Auto-start polling after a short delay setTimeout(startPolling, 500); diff --git a/examples/threejs-server/package.json b/examples/threejs-server/package.json index d691c8aa..2b5ce8b9 100644 --- a/examples/threejs-server/package.json +++ b/examples/threejs-server/package.json @@ -4,14 +4,14 @@ "private": true, "type": "module", "scripts": { - "build": "INPUT=mcp-app.html vite build", - "watch": "INPUT=mcp-app.html vite build --watch", + "build": "cross-env INPUT=mcp-app.html vite build", + "watch": "cross-env INPUT=mcp-app.html vite build --watch", "serve:http": "bun server.ts", "serve:stdio": "bun server.ts --stdio", "start": "npm run start:http", - "start:http": "NODE_ENV=development npm run build && npm run serve:http", - "start:stdio": "NODE_ENV=development npm run build && npm run serve:stdio", - "dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve: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", + "dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve:http'" }, "dependencies": { "@modelcontextprotocol/ext-apps": "../..", @@ -29,7 +29,6 @@ "@types/react-dom": "^19.2.2", "@types/three": "^0.181.0", "@vitejs/plugin-react": "^4.3.4", - "bun": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", diff --git a/examples/threejs-server/server.ts b/examples/threejs-server/server.ts index 938ec82d..6c345131 100644 --- a/examples/threejs-server/server.ts +++ b/examples/threejs-server/server.ts @@ -9,7 +9,12 @@ import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; -import { RESOURCE_MIME_TYPE, type McpUiToolMeta } from "../../dist/src/app"; +import { + registerAppTool, + registerAppResource, + RESOURCE_MIME_TYPE, + type McpUiToolMeta, +} from "@modelcontextprotocol/ext-apps/server"; import { startServer } from "../shared/server-utils.js"; const DIST_DIR = path.join(import.meta.dirname, "dist"); @@ -142,7 +147,8 @@ function createServer(): McpServer { }); // Tool 1: show_threejs_scene - server.registerTool( + registerAppTool( + server, "show_threejs_scene", { title: "Show Three.js Scene", @@ -175,7 +181,8 @@ function createServer(): McpServer { ); // Tool 2: learn_threejs - server.registerTool( + registerAppTool( + server, "learn_threejs", { title: "Learn Three.js", @@ -191,7 +198,8 @@ function createServer(): McpServer { ); // Resource registration - server.registerResource( + registerAppResource( + server, resourceUri, resourceUri, { mimeType: RESOURCE_MIME_TYPE, description: "Three.js Widget UI" }, diff --git a/examples/wiki-explorer-server/package.json b/examples/wiki-explorer-server/package.json index 4468d586..89eece39 100644 --- a/examples/wiki-explorer-server/package.json +++ b/examples/wiki-explorer-server/package.json @@ -4,14 +4,14 @@ "private": true, "type": "module", "scripts": { - "build": "INPUT=mcp-app.html vite build", - "watch": "INPUT=mcp-app.html vite build --watch", + "build": "cross-env INPUT=mcp-app.html vite build", + "watch": "cross-env INPUT=mcp-app.html vite build --watch", "serve:http": "bun server.ts", "serve:stdio": "bun server.ts --stdio", "start": "npm run start:http", - "start:http": "NODE_ENV=development npm run build && npm run serve:http", - "start:stdio": "NODE_ENV=development npm run build && npm run serve:stdio", - "dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve: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", + "dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve:http'" }, "dependencies": { "@modelcontextprotocol/ext-apps": "../..", @@ -23,7 +23,6 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.0", "@types/node": "^22.0.0", - "bun": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", diff --git a/examples/wiki-explorer-server/server.ts b/examples/wiki-explorer-server/server.ts index adeef6c6..89a60bd9 100644 --- a/examples/wiki-explorer-server/server.ts +++ b/examples/wiki-explorer-server/server.ts @@ -8,7 +8,12 @@ import * as cheerio from "cheerio"; import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; -import { RESOURCE_MIME_TYPE, type McpUiToolMeta } from "../../dist/src/app"; +import { + registerAppTool, + registerAppResource, + RESOURCE_MIME_TYPE, + type McpUiToolMeta, +} from "@modelcontextprotocol/ext-apps/server"; import { startServer } from "../shared/server-utils.js"; const DIST_DIR = path.join(import.meta.dirname, "dist"); @@ -76,7 +81,8 @@ function createServer(): McpServer { // Register the get-first-degree-links tool and its associated UI resource const resourceUri = "ui://wiki-explorer/mcp-app.html"; - server.registerTool( + registerAppTool( + server, "get-first-degree-links", { title: "Get First-Degree Links", @@ -124,7 +130,8 @@ function createServer(): McpServer { }, ); - server.registerResource( + registerAppResource( + server, resourceUri, resourceUri, { mimeType: RESOURCE_MIME_TYPE }, diff --git a/examples/wiki-explorer-server/src/mcp-app.ts b/examples/wiki-explorer-server/src/mcp-app.ts index 8f9026c7..2afa81fc 100644 --- a/examples/wiki-explorer-server/src/mcp-app.ts +++ b/examples/wiki-explorer-server/src/mcp-app.ts @@ -1,7 +1,7 @@ /** * Wiki Explorer - Force-directed graph visualization of Wikipedia link networks */ -import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps"; +import { App } from "@modelcontextprotocol/ext-apps"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { forceCenter, @@ -367,4 +367,4 @@ app.onerror = (err) => { }; // Connect to host -app.connect(new PostMessageTransport(window.parent)); +app.connect(); diff --git a/package-lock.json b/package-lock.json index 099cff78..93ce5f2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,28 +7,29 @@ "": { "name": "@modelcontextprotocol/ext-apps", "version": "0.1.0", + "hasInstallScript": true, "license": "MIT", "workspaces": [ "examples/*" ], "dependencies": { "@modelcontextprotocol/sdk": "^1.24.3", - "react": "^19.2.0", - "react-dom": "^19.2.0" + "prettier": "^3.6.2" }, "devDependencies": { "@playwright/test": "^1.52.0", "@types/bun": "^1.3.2", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", - "bun": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", + "cross-env": "^10.1.0", "esbuild": "^0.25.12", "express": "^5.1.0", "husky": "^9.1.7", "nodemon": "^3.1.0", - "prettier": "^3.6.2", + "react": "^19.2.0", + "react-dom": "^19.2.0", "ts-to-zod": "^5.1.0", "tsx": "^4.21.0", "typedoc": "^0.28.14", @@ -36,6 +37,17 @@ "zod": "^4.1.13" }, "optionalDependencies": { + "@oven/bun-darwin-aarch64": "^1.3.4", + "@oven/bun-darwin-x64": "^1.3.4", + "@oven/bun-darwin-x64-baseline": "^1.3.4", + "@oven/bun-linux-aarch64": "^1.3.4", + "@oven/bun-linux-aarch64-musl": "^1.3.4", + "@oven/bun-linux-x64": "^1.3.4", + "@oven/bun-linux-x64-baseline": "^1.3.4", + "@oven/bun-linux-x64-musl": "^1.3.4", + "@oven/bun-linux-x64-musl-baseline": "^1.3.4", + "@oven/bun-windows-x64": "^1.3.4", + "@oven/bun-windows-x64-baseline": "^1.3.4", "@rollup/rollup-darwin-arm64": "^4.53.3", "@rollup/rollup-darwin-x64": "^4.53.3", "@rollup/rollup-linux-arm64-gnu": "^4.53.3", @@ -43,7 +55,17 @@ "@rollup/rollup-win32-x64-msvc": "^4.53.3" }, "peerDependencies": { + "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 + } } }, "examples/basic-host": { @@ -62,7 +84,6 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^4.3.4", - "bun": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", @@ -75,6 +96,8 @@ }, "examples/basic-host/node_modules/@types/node": { "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "dev": true, "license": "MIT", "dependencies": { @@ -104,7 +127,6 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^4.3.4", - "bun": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", @@ -115,6 +137,8 @@ }, "examples/basic-server-react/node_modules/@types/node": { "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "dev": true, "license": "MIT", "dependencies": { @@ -139,7 +163,6 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.0", "@types/node": "^22.0.0", - "bun": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", @@ -150,6 +173,8 @@ }, "examples/basic-server-vanillajs/node_modules/@types/node": { "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "dev": true, "license": "MIT", "dependencies": { @@ -175,7 +200,6 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.0", "@types/node": "^22.0.0", - "bun": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", @@ -186,6 +210,8 @@ }, "examples/budget-allocator-server/node_modules/@types/node": { "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "dev": true, "license": "MIT", "dependencies": { @@ -215,7 +241,6 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^4.3.4", - "bun": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", @@ -226,6 +251,8 @@ }, "examples/cohort-heatmap-server/node_modules/@types/node": { "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "dev": true, "license": "MIT", "dependencies": { @@ -251,7 +278,6 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.0", "@types/node": "^22.0.0", - "bun": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", @@ -262,6 +288,8 @@ }, "examples/customer-segmentation-server/node_modules/@types/node": { "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "dev": true, "license": "MIT", "dependencies": { @@ -292,7 +320,6 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^4.3.4", - "bun": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", @@ -303,6 +330,8 @@ }, "examples/scenario-modeler-server/node_modules/@types/node": { "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "dev": true, "license": "MIT", "dependencies": { @@ -329,7 +358,6 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.0", "@types/node": "^22.0.0", - "bun": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", @@ -340,6 +368,8 @@ }, "examples/system-monitor-server/node_modules/@types/node": { "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "dev": true, "license": "MIT", "dependencies": { @@ -371,7 +401,6 @@ "@types/react-dom": "^19.2.2", "@types/three": "^0.181.0", "@vitejs/plugin-react": "^4.3.4", - "bun": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", @@ -382,6 +411,8 @@ }, "examples/threejs-server/node_modules/@types/node": { "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "dev": true, "license": "MIT", "dependencies": { @@ -407,7 +438,6 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.0", "@types/node": "^22.0.0", - "bun": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", @@ -764,6 +794,13 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1384,7 +1421,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1398,7 +1434,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1412,7 +1447,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1426,7 +1460,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1440,7 +1473,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1454,7 +1486,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1468,7 +1499,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1482,7 +1512,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1496,7 +1525,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1510,7 +1538,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1524,7 +1551,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1970,13 +1996,13 @@ } }, "node_modules/@types/bun": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.4.tgz", - "integrity": "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.3.tgz", + "integrity": "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g==", "dev": true, "license": "MIT", "dependencies": { - "bun-types": "1.3.4" + "bun-types": "1.3.3" } }, "node_modules/@types/chai": { @@ -2067,9 +2093,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.0.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.0.tgz", - "integrity": "sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew==", + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2481,9 +2507,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.6", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.6.tgz", - "integrity": "sha512-v9BVVpOTLB59C9E7aSnmIF8h7qRsFpx+A2nugVMTszEOMcfjlZMsXRm4LF23I3Z9AJxc8ANpIvzbzONoX9VJlg==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.4.tgz", + "integrity": "sha512-ZCQ9GEWl73BVm8bu5Fts8nt7MHdbt5vY9bP6WGnUh+r3l8M7CgfyTlwsgCbMC66BNxPr6Xoce3j66Ms5YUQTNA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2614,44 +2640,10 @@ "resolved": "examples/budget-allocator-server", "link": true }, - "node_modules/bun": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/bun/-/bun-1.3.4.tgz", - "integrity": "sha512-xV6KgD5ImquuKsoghzbWmYzeCXmmSgN6yJGz444hri2W+NGKNRFUNrEhy9+/rRXbvNA2qF0K0jAwqFNy1/GhBg==", - "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.4", - "@oven/bun-darwin-x64": "1.3.4", - "@oven/bun-darwin-x64-baseline": "1.3.4", - "@oven/bun-linux-aarch64": "1.3.4", - "@oven/bun-linux-aarch64-musl": "1.3.4", - "@oven/bun-linux-x64": "1.3.4", - "@oven/bun-linux-x64-baseline": "1.3.4", - "@oven/bun-linux-x64-musl": "1.3.4", - "@oven/bun-linux-x64-musl-baseline": "1.3.4", - "@oven/bun-windows-x64": "1.3.4", - "@oven/bun-windows-x64-baseline": "1.3.4" - } - }, "node_modules/bun-types": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.4.tgz", - "integrity": "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.3.tgz", + "integrity": "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2707,9 +2699,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001760", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", - "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", + "version": "1.0.30001759", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", + "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", "dev": true, "funding": [ { @@ -3116,6 +3108,24 @@ "node": ">= 0.10" } }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3532,9 +3542,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "version": "1.5.266", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.266.tgz", + "integrity": "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg==", "dev": true, "license": "ISC" }, @@ -3760,9 +3770,9 @@ } }, "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==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5220,7 +5230,6 @@ "version": "3.7.4", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", - "dev": true, "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -5865,9 +5874,9 @@ "link": true }, "node_modules/systeminformation": { - "version": "5.27.13", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.27.13.tgz", - "integrity": "sha512-geeE/7eNDoOhdc9j+qCsLlwbcyh0HnqhOZzmfNK4WBioWGUZbhwYrg+YZsZ3UJh4tmybQsnDuqzr3UoumMifew==", + "version": "5.27.11", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.27.11.tgz", + "integrity": "sha512-K3Lto/2m3K2twmKHdgx5B+0in9qhXK4YnoT9rIlgwN/4v7OV5c8IjbeAUkuky/6VzCQC7iKCAqi8rZathCdjHg==", "license": "MIT", "os": [ "darwin", @@ -7441,9 +7450,9 @@ } }, "node_modules/zod": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", - "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", + "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 01f9d700..cd3085fc 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,10 @@ "types": "./dist/src/app-bridge.d.ts", "default": "./dist/src/app-bridge.js" }, + "./server": { + "types": "./dist/src/server/index.d.ts", + "default": "./dist/src/server/index.js" + }, "./schema.json": "./dist/src/generated/schema.json" }, "files": [ @@ -32,8 +36,9 @@ "examples/*" ], "scripts": { - "generate:schemas": "tsx scripts/generate-schemas.ts && prettier --write 'src/generated/**/*'", - "build": "npm run generate:schemas && bun build.bun.ts", + "postinstall": "node scripts/setup-bun.mjs || echo 'setup-bun.mjs failed or not available'", + "generate:schemas": "tsx scripts/generate-schemas.ts && prettier --write \"src/generated/**/*\"", + "build": "npm run generate:schemas && node scripts/run-bun.mjs build.bun.ts", "prepack": "npm run build", "build:all": "npm run build && npm run examples:build", "test": "bun test src", @@ -46,12 +51,11 @@ "examples:start": "NODE_ENV=development npm run build && bun examples/run-all.ts start", "examples:dev": "NODE_ENV=development bun examples/run-all.ts dev", "watch": "nodemon --watch src --ext ts,tsx --exec 'bun build.bun.ts'", - "prepare": "npm run build && husky", + "prepare": "node scripts/setup-bun.mjs && npm run build && husky", "docs": "typedoc", "docs:watch": "typedoc --watch", - "prettier:base-cmd": "prettier -u --ignore-path ./.gitignore --ignore-path ./.prettierignore", - "prettier": "yarn prettier:base-cmd \"$(pwd)/**/*.{js,jsx,ts,tsx,mjs,json,md,yml,yaml}\" --check", - "prettier:fix": "yarn prettier:base-cmd \"$(pwd)/**/*.{js,jsx,ts,tsx,mjs,json,md,yml,yaml}\" --write --list-different" + "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" }, "author": "Olivier Chafik", "devDependencies": { @@ -59,9 +63,11 @@ "@types/bun": "^1.3.2", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", - "bun": "^1.3.2", + "react": "^19.2.0", + "react-dom": "^19.2.0", "concurrently": "^9.2.1", "cors": "^2.8.5", + "cross-env": "^10.1.0", "esbuild": "^0.25.12", "express": "^5.1.0", "husky": "^9.1.7", @@ -73,15 +79,34 @@ "zod": "^4.1.13" }, "peerDependencies": { + "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 + } + }, "dependencies": { - "prettier": "^3.6.2", "@modelcontextprotocol/sdk": "^1.24.3", - "react": "^19.2.0", - "react-dom": "^19.2.0" + "prettier": "^3.6.2" }, "optionalDependencies": { + "@oven/bun-darwin-aarch64": "^1.3.4", + "@oven/bun-darwin-x64": "^1.3.4", + "@oven/bun-darwin-x64-baseline": "^1.3.4", + "@oven/bun-linux-aarch64": "^1.3.4", + "@oven/bun-linux-aarch64-musl": "^1.3.4", + "@oven/bun-linux-x64": "^1.3.4", + "@oven/bun-linux-x64-baseline": "^1.3.4", + "@oven/bun-linux-x64-musl": "^1.3.4", + "@oven/bun-linux-x64-musl-baseline": "^1.3.4", + "@oven/bun-windows-x64": "^1.3.4", + "@oven/bun-windows-x64-baseline": "^1.3.4", "@rollup/rollup-darwin-arm64": "^4.53.3", "@rollup/rollup-darwin-x64": "^4.53.3", "@rollup/rollup-linux-arm64-gnu": "^4.53.3", diff --git a/scripts/run-bun.mjs b/scripts/run-bun.mjs new file mode 100644 index 00000000..181858e1 --- /dev/null +++ b/scripts/run-bun.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** + * Wrapper script to run bun with the correct path. + * This is needed because during npm install's prepare hook, + * node_modules/.bin is not in PATH yet. + */ +import { spawn } from "child_process"; +import { existsSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = join(__dirname, ".."); +const isWindows = process.platform === "win32"; +const bunExe = isWindows ? "bun.exe" : "bun"; + +// Find bun binary +const bunPaths = [ + join(projectRoot, "node_modules", ".bin", bunExe), + // Fallback to system bun + "bun", +]; + +let bunPath = null; +for (const p of bunPaths) { + if (p === "bun" || existsSync(p)) { + bunPath = p; + break; + } +} + +if (!bunPath) { + console.error("Could not find bun binary"); + process.exit(1); +} + +// Run bun with the provided arguments +const args = process.argv.slice(2); +const child = spawn(bunPath, args, { + stdio: "inherit", + shell: isWindows, +}); + +child.on("exit", (code) => { + process.exit(code ?? 0); +}); + +child.on("error", (err) => { + console.error("Failed to run bun:", err.message); + process.exit(1); +}); diff --git a/scripts/setup-bun.mjs b/scripts/setup-bun.mjs new file mode 100644 index 00000000..c7ec0dba --- /dev/null +++ b/scripts/setup-bun.mjs @@ -0,0 +1,262 @@ +#!/usr/bin/env node +// Immediate log to verify script execution +console.log("[setup-bun] Script loaded"); + +/** + * Postinstall script to set up bun from platform-specific optional dependencies. + * Handles Windows ARM64 by downloading x64-baseline via emulation. + */ +import { + existsSync, + mkdirSync, + symlinkSync, + unlinkSync, + copyFileSync, + chmodSync, + writeFileSync, +} from "fs"; +import { join, dirname } from "path"; +import { spawnSync } from "child_process"; +import { fileURLToPath } from "url"; +import { get } from "https"; +import { createGunzip } from "zlib"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = join(__dirname, ".."); +const nodeModules = join(projectRoot, "node_modules"); +const binDir = join(nodeModules, ".bin"); + +const os = process.platform; +const arch = process.arch; +const isWindows = os === "win32"; +const bunExe = isWindows ? "bun.exe" : "bun"; + +// Detect libc type on Linux (glibc vs musl) +function detectLibc() { + if (os !== "linux") return null; + + // Check for musl-specific loader + const muslLoaders = [ + `/lib/ld-musl-${arch === "arm64" ? "aarch64" : "x86_64"}.so.1`, + "/lib/ld-musl-x86_64.so.1", + "/lib/ld-musl-aarch64.so.1", + ]; + + for (const loader of muslLoaders) { + if (existsSync(loader)) { + console.log(` Detected musl libc (found ${loader})`); + return "musl"; + } + } + + // Default to glibc on Linux + console.log(" Detected glibc (no musl loader found)"); + return "glibc"; +} + +// Platform to package mapping (matches @oven/bun-* package names) +// For Linux, separate glibc and musl packages +const platformPackages = { + darwin: { + arm64: ["bun-darwin-aarch64"], + x64: ["bun-darwin-x64", "bun-darwin-x64-baseline"], + }, + linux: { + arm64: { + glibc: ["bun-linux-aarch64"], + musl: ["bun-linux-aarch64-musl"], + }, + x64: { + glibc: ["bun-linux-x64", "bun-linux-x64-baseline"], + musl: ["bun-linux-x64-musl", "bun-linux-x64-musl-baseline"], + }, + }, + win32: { + x64: ["bun-windows-x64", "bun-windows-x64-baseline"], + arm64: ["bun-windows-x64-baseline"], // x64 runs via emulation on ARM64 + }, +}; + +function findBunBinary() { + let packages = platformPackages[os]?.[arch]; + + // For Linux, select packages based on libc type + if (os === "linux" && packages && typeof packages === "object") { + const libc = detectLibc(); + packages = packages[libc] || []; + } + + packages = packages || []; + console.log( + `Looking for bun packages: ${packages.join(", ") || "(none for this platform)"}`, + ); + + for (const pkg of packages) { + const binPath = join(nodeModules, "@oven", pkg, "bin", bunExe); + console.log(` Checking: ${binPath}`); + if (existsSync(binPath)) { + console.log(` Found bun at: ${binPath}`); + return binPath; + } else { + console.log(` Not found`); + } + } + + return null; +} + +async function downloadBunForWindowsArm64() { + // Windows ARM64 can run x64 binaries via emulation + const pkg = "bun-windows-x64-baseline"; + const version = "1.3.4"; + const url = `https://registry.npmjs.org/@oven/${pkg}/-/${pkg}-${version}.tgz`; + const destDir = join(nodeModules, "@oven", pkg); + + console.log(`Downloading ${pkg} for Windows ARM64 emulation...`); + + return new Promise((resolve, reject) => { + get(url, (response) => { + if (response.statusCode === 302 || response.statusCode === 301) { + get(response.headers.location, handleResponse).on("error", reject); + } else { + handleResponse(response); + } + + function handleResponse(res) { + if (res.statusCode !== 200) { + reject(new Error(`Failed to download: ${res.statusCode}`)); + return; + } + + const chunks = []; + const gunzip = createGunzip(); + + res.pipe(gunzip); + + gunzip.on("data", (chunk) => chunks.push(chunk)); + gunzip.on("end", () => { + try { + extractTar(Buffer.concat(chunks), destDir); + const binPath = join(destDir, "bin", bunExe); + if (existsSync(binPath)) { + resolve(binPath); + } else { + reject(new Error("Binary not found after extraction")); + } + } catch (err) { + reject(err); + } + }); + gunzip.on("error", reject); + } + }).on("error", reject); + }); +} + +function extractTar(buffer, destDir) { + // Simple tar extraction (512-byte blocks) + let offset = 0; + while (offset < buffer.length) { + const name = buffer + .toString("utf-8", offset, offset + 100) + .replace(/\0.*$/, "") + .replace("package/", ""); + const size = parseInt( + buffer.toString("utf-8", offset + 124, offset + 136).trim(), + 8, + ); + + offset += 512; + + if (!isNaN(size) && size > 0 && name) { + const filePath = join(destDir, name); + const fileDir = dirname(filePath); + if (!existsSync(fileDir)) { + mkdirSync(fileDir, { recursive: true }); + } + const content = buffer.subarray(offset, offset + size); + writeFileSync(filePath, content); + + // Make executable + if (name.endsWith(bunExe) || name === "bin/bun") { + try { + chmodSync(filePath, 0o755); + } catch {} + } + + offset += Math.ceil(size / 512) * 512; + } + } +} + +function setupBinLink(bunPath) { + if (!existsSync(binDir)) { + mkdirSync(binDir, { recursive: true }); + } + + const bunLink = join(binDir, bunExe); + const bunxLink = join(binDir, isWindows ? "bunx.exe" : "bunx"); + + // Remove existing links + for (const link of [bunLink, bunxLink]) { + try { + unlinkSync(link); + } catch {} + } + + if (isWindows) { + // On Windows, copy the binary (symlinks may not work without admin) + copyFileSync(bunPath, bunLink); + copyFileSync(bunPath, bunxLink); + } else { + // On Unix, use symlinks + symlinkSync(bunPath, bunLink); + symlinkSync(bunPath, bunxLink); + } + + console.log(`Bun linked to: ${bunLink}`); +} + +// Force immediate output +process.stdout.write("[setup-bun] Script starting...\n"); + +async function main() { + process.stdout.write(`[setup-bun] Setting up bun for ${os} ${arch}...\n`); + process.stdout.write(`[setup-bun] Project root: ${projectRoot}\n`); + process.stdout.write(`[setup-bun] Node modules: ${nodeModules}\n`); + + let bunPath = findBunBinary(); + + if (!bunPath && os === "win32" && arch === "arm64") { + try { + bunPath = await downloadBunForWindowsArm64(); + } catch (err) { + console.error("Failed to download bun for Windows ARM64:", err.message); + } + } + + if (!bunPath) { + console.log("No bun binary found in optional dependencies."); + console.log("Bun will need to be installed separately."); + console.log("See: https://bun.sh/docs/installation"); + process.exit(0); // Don't fail the install + } + + try { + setupBinLink(bunPath); + + // Verify installation + const result = spawnSync(bunPath, ["--version"], { encoding: "utf-8" }); + if (result.status === 0) { + console.log(`Bun ${result.stdout.trim()} installed successfully!`); + } + } catch (err) { + console.error("Failed to set up bun:", err.message); + process.exit(0); // Don't fail the install + } +} + +main().catch((err) => { + console.error(err); + process.exit(0); // Don't fail the install +}); diff --git a/src/app.ts b/src/app.ts index b8a92388..4a078010 100644 --- a/src/app.ts +++ b/src/app.ts @@ -16,6 +16,7 @@ import { PingRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { AppNotification, AppRequest, AppResult } from "./types"; +import { PostMessageTransport } from "./message-transport"; import { LATEST_PROTOCOL_VERSION, McpUiAppCapabilities, @@ -1025,7 +1026,7 @@ export class App extends Protocol { * @see {@link PostMessageTransport} for the typical transport implementation */ override async connect( - transport: Transport, + transport: Transport = new PostMessageTransport(window.parent), options?: RequestOptions, ): Promise { await super.connect(transport); diff --git a/src/server/index.test.ts b/src/server/index.test.ts new file mode 100644 index 00000000..2b7b45ea --- /dev/null +++ b/src/server/index.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, mock } from "bun:test"; +import { + registerAppTool, + registerAppResource, + RESOURCE_URI_META_KEY, + RESOURCE_MIME_TYPE, +} from "./index"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +describe("registerAppTool", () => { + it("should pass through config to server.registerTool", () => { + let capturedName: string | undefined; + let capturedConfig: Record | undefined; + let capturedHandler: unknown; + + const mockServer = { + registerTool: mock( + (name: string, config: Record, handler: unknown) => { + capturedName = name; + capturedConfig = config; + capturedHandler = handler; + }, + ), + registerResource: mock(() => {}), + }; + + const handler = async () => ({ + content: [{ type: "text" as const, text: "ok" }], + }); + + registerAppTool( + mockServer as unknown as Pick, + "my-tool", + { + title: "My Tool", + description: "A test tool", + _meta: { + [RESOURCE_URI_META_KEY]: "ui://test/widget.html", + }, + }, + handler, + ); + + expect(mockServer.registerTool).toHaveBeenCalledTimes(1); + expect(capturedName).toBe("my-tool"); + expect(capturedConfig?.title).toBe("My Tool"); + expect(capturedConfig?.description).toBe("A test tool"); + expect( + (capturedConfig?._meta as Record)?.[ + RESOURCE_URI_META_KEY + ], + ).toBe("ui://test/widget.html"); + expect(capturedHandler).toBe(handler); + }); +}); + +describe("registerAppResource", () => { + it("should register a resource with default MIME type", () => { + let capturedName: string | undefined; + let capturedUri: string | undefined; + let capturedConfig: Record | undefined; + + const mockServer = { + registerTool: mock(() => {}), + registerResource: mock( + (name: string, uri: string, config: Record) => { + capturedName = name; + capturedUri = uri; + capturedConfig = config; + }, + ), + }; + + const callback = async () => ({ + contents: [ + { + uri: "ui://test/widget.html", + mimeType: RESOURCE_MIME_TYPE, + text: "", + }, + ], + }); + + registerAppResource( + mockServer as unknown as Pick, + "My Resource", + "ui://test/widget.html", + { + description: "A test resource", + _meta: { ui: {} }, + }, + callback, + ); + + expect(mockServer.registerResource).toHaveBeenCalledTimes(1); + expect(capturedName).toBe("My Resource"); + expect(capturedUri).toBe("ui://test/widget.html"); + expect(capturedConfig?.mimeType).toBe(RESOURCE_MIME_TYPE); + expect(capturedConfig?.description).toBe("A test resource"); + }); + + it("should allow custom MIME type to override default", () => { + let capturedConfig: Record | undefined; + + const mockServer = { + registerTool: mock(() => {}), + registerResource: mock( + (_name: string, _uri: string, config: Record) => { + capturedConfig = config; + }, + ), + }; + + registerAppResource( + mockServer as unknown as Pick, + "My Resource", + "ui://test/widget.html", + { + mimeType: "text/html", + _meta: { ui: {} }, + }, + async () => ({ + contents: [ + { + uri: "ui://test/widget.html", + mimeType: "text/html", + text: "", + }, + ], + }), + ); + + // Custom mimeType should override the default + expect(capturedConfig?.mimeType).toBe("text/html"); + }); + + it("should call the callback when handler is invoked", async () => { + let capturedHandler: (() => Promise) | undefined; + + const mockServer = { + registerTool: mock(() => {}), + registerResource: mock( + ( + _name: string, + _uri: string, + _config: unknown, + handler: () => Promise, + ) => { + capturedHandler = handler; + }, + ), + }; + + const expectedResult = { + contents: [ + { + uri: "ui://test/widget.html", + mimeType: RESOURCE_MIME_TYPE, + text: "content", + }, + ], + }; + const callback = mock(async () => expectedResult); + + registerAppResource( + mockServer as unknown as Pick, + "My Resource", + "ui://test/widget.html", + { _meta: { ui: {} } }, + callback, + ); + + expect(capturedHandler).toBeDefined(); + const result = await capturedHandler!(); + + expect(callback).toHaveBeenCalledTimes(1); + expect(result).toEqual(expectedResult); + }); +}); diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 00000000..5b9890b9 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,163 @@ +/** + * Server Helpers for MCP Apps. + * + * @module server-helpers + */ + +import { + RESOURCE_URI_META_KEY as _RESOURCE_URI_META_KEY, + RESOURCE_MIME_TYPE, +} from "../app.js"; +import type { McpUiResourceMeta, McpUiToolMeta } from "../app.js"; +import type { + McpServer, + ResourceMetadata, + ToolCallback, + ReadResourceCallback, +} from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; +import type { ZodRawShape } from "zod"; + +// Re-export SDK types for convenience +export type { ResourceMetadata, ToolCallback, ReadResourceCallback }; + +// Re-export for convenience +export const RESOURCE_URI_META_KEY = _RESOURCE_URI_META_KEY; +export { RESOURCE_MIME_TYPE }; +export type { McpUiToolMeta }; + +/** + * Tool configuration (same as McpServer.registerTool). + */ +export interface ToolConfig { + title?: string; + description?: string; + inputSchema?: ZodRawShape; + annotations?: ToolAnnotations; + _meta?: Record; +} + +/** + * MCP App Tool configuration for `registerAppTool`. + */ +export interface McpUiAppToolConfig extends ToolConfig { + _meta: { + /** + * New nested format (preferred). + * Contains `resourceUri` and optional `visibility` array. + * + * @example { resourceUri: "ui://weather/widget.html", visibility: ["model", "app"] } + */ + ui?: McpUiToolMeta; + + /** + * URI of the UI resource to display for this tool. + * @deprecated Use `ui.resourceUri` instead. + * + * @example "ui://weather/widget.html" + */ + [RESOURCE_URI_META_KEY]?: string; + }; +} + +/** + * MCP App Resource configuration for `registerAppResource`. + */ +export interface McpUiAppResourceConfig extends ResourceMetadata { + _meta: { + ui: McpUiResourceMeta; + }; +} + +/** + * Register an app tool with the MCP server. + * + * This is a convenience wrapper around `server.registerTool` that: + * - Accepts the new `_meta.ui` format with `resourceUri` and `visibility` + * - Normalizes to flat format for SDK compatibility + * - Supports the deprecated `_meta["ui/resourceUri"]` format for backward compat + * + * @param server - The MCP server instance + * @param name - Tool name/identifier + * @param config - Tool configuration with `_meta.ui` field + * @param handler - Tool handler function + * + * @example Using new format (preferred) + * ```typescript + * import { registerAppTool, McpUiToolMeta } from '@modelcontextprotocol/ext-apps/server'; + * import { z } from 'zod'; + * + * registerAppTool(server, "get-weather", { + * title: "Get Weather", + * description: "Get current weather for a location", + * inputSchema: { location: z.string() }, + * _meta: { + * ui: { resourceUri: "ui://weather/widget.html" } as McpUiToolMeta, + * }, + * }, async (args) => { + * const weather = await fetchWeather(args.location); + * return { content: [{ type: "text", text: JSON.stringify(weather) }] }; + * }); + * ``` + */ +export function registerAppTool( + server: Pick, + name: string, + config: McpUiAppToolConfig, + handler: ToolCallback, +): void { + // Normalize: ensure flat format is set for SDK compatibility + const normalizedMeta = { ...config._meta }; + if (config._meta.ui?.resourceUri && !config._meta[RESOURCE_URI_META_KEY]) { + normalizedMeta[RESOURCE_URI_META_KEY] = config._meta.ui.resourceUri; + } + server.registerTool(name, { ...config, _meta: normalizedMeta }, handler); +} + +/** + * Register an app resource with the MCP server. + * + * This is a convenience wrapper around `server.registerResource` that: + * - Defaults the MIME type to "text/html;profile=mcp-app" + * - Provides a cleaner API matching the SDK's callback signature + * + * @param server - The MCP server instance + * @param name - Human-readable resource name + * @param uri - Resource URI (should match the `ui` field in tool config) + * @param config - Resource configuration + * @param readCallback - Callback that returns the resource contents + * + * @example + * ```typescript + * import { registerAppResource } from '@modelcontextprotocol/ext-apps/server'; + * + * registerAppResource(server, "Weather Widget", "ui://weather/widget.html", { + * description: "Interactive weather display", + * mimeType: RESOURCE_MIME_TYPE, + * }, async () => ({ + * contents: [{ + * uri: "ui://weather/widget.html", + * mimeType: RESOURCE_MIME_TYPE, + * text: await fs.readFile("dist/widget.html", "utf-8"), + * }], + * })); + * ``` + */ +export function registerAppResource( + server: Pick, + name: string, + uri: string, + config: McpUiAppResourceConfig, + readCallback: ReadResourceCallback, +): void { + server.registerResource( + name, + uri, + { + // Default MIME type for MCP App UI resources (can still be overridden by config below) + mimeType: RESOURCE_MIME_TYPE, + ...config, + }, + readCallback, + ); +}