diff --git a/package-lock.json b/package-lock.json index f1d7e203..6605a022 100644 --- a/package-lock.json +++ b/package-lock.json @@ -476,7 +476,7 @@ "version": "0.4.1", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^0.4.0", + "@modelcontextprotocol/ext-apps": "^0.4.1", "@modelcontextprotocol/sdk": "^1.24.0", "zod": "^4.1.13" }, @@ -625,12 +625,18 @@ }, "examples/qr-server": { "name": "@modelcontextprotocol/server-qr", - "version": "1.0.0" + "version": "1.0.0", + "dependencies": { + "@modelcontextprotocol/ext-apps": "^0.4.1" + } }, "examples/say-server": { "name": "@modelcontextprotocol/server-say", "version": "0.4.1", - "license": "MIT" + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/ext-apps": "^0.4.1" + } }, "examples/scenario-modeler-server": { "name": "@modelcontextprotocol/server-scenario-modeler", @@ -993,7 +999,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -3007,13 +3012,13 @@ ] }, "node_modules/@playwright/test": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", - "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz", + "integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.57.0" + "playwright": "1.58.0" }, "bin": { "playwright": "cli.js" @@ -3106,6 +3111,19 @@ "node": ">= 8.0.0" } }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "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", @@ -3514,7 +3532,6 @@ "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", @@ -3721,7 +3738,6 @@ "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3746,7 +3762,6 @@ "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4149,7 +4164,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4259,6 +4273,19 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -4475,7 +4502,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4549,9 +4575,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001765", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz", - "integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==", + "version": "1.0.30001766", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", + "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", "dev": true, "funding": [ { @@ -5191,7 +5217,6 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -5676,7 +5701,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -6090,6 +6114,16 @@ "he": "bin/he" } }, + "node_modules/hono": { + "version": "4.11.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.5.tgz", + "integrity": "sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-entities": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", @@ -6790,6 +6824,19 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -7137,13 +7184,13 @@ "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": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -7159,13 +7206,13 @@ } }, "node_modules/playwright": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", - "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz", + "integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.57.0" + "playwright-core": "1.58.0" }, "bin": { "playwright": "cli.js" @@ -7178,9 +7225,9 @@ } }, "node_modules/playwright-core": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", - "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz", + "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -7238,7 +7285,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz", "integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -7334,7 +7380,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7374,6 +7419,19 @@ "node": ">=8.10.0" } }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7433,7 +7491,6 @@ "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -7555,7 +7612,6 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz", "integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" } @@ -7846,7 +7902,6 @@ "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.10.tgz", "integrity": "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", @@ -8025,7 +8080,6 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.48.0.tgz", "integrity": "sha512-+NUe82VoFP1RQViZI/esojx70eazGF4u0O/9ucqZ4rPcOZD+n5EVp17uYsqwdzjUjZyTpGKunHbDziW6AIAVkQ==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -8329,20 +8383,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "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==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", @@ -9096,9 +9136,8 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9192,7 +9231,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -9345,20 +9383,6 @@ "vite": "5.x || 6.x || 7.x" } }, - "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==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/vitefu": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", @@ -9452,25 +9476,11 @@ } } }, - "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", "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.27", "@vue/compiler-sfc": "3.5.27", @@ -9643,7 +9653,6 @@ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -9694,7 +9703,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index 25d84e63..d60e7148 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -640,6 +640,25 @@ interface HostCapabilities { baseUriDomains?: string[]; }; }; + /** Supported content block modalities for ui/message requests. */ + message?: SupportedContentBlockModalities; + /** Supported content block modalities for ui/update-model-context requests. */ + updateModelContext?: SupportedContentBlockModalities; +} + +interface SupportedContentBlockModalities { + /** Host supports text content blocks. */ + text?: {}; + /** Host supports image content blocks. */ + image?: {}; + /** Host supports audio content blocks. */ + audio?: {}; + /** Host supports resource content blocks. */ + resource?: {}; + /** Host supports resource link content blocks. */ + resourceLink?: {}; + /** Host supports structured content (updateModelContext only). */ + structuredContent?: {}; } ``` @@ -926,10 +945,8 @@ Host SHOULD open the URL in the user's default browser or a new tab. method: "ui/message", params: { role: "user", - content: { - type: "text", - text: string - } + content: ContentBlock[] // text, image, audio, resource, resource_link + // (subject to hostCapabilities.message modalities) } } @@ -953,6 +970,8 @@ Host SHOULD open the URL in the user's default browser or a new tab. Host behavior: * Host SHOULD add the message to the conversation context, preserving the specified role. * Host MAY request user consent. +* Host SHOULD declare a `message` capability with supported modalities during initialization. +* Host MUST respond with a JSON-RPC error if any content block type in the request is not in the declared `hostCapabilities.message`. `ui/request-display-mode` - Request host to change display mode @@ -1029,6 +1048,8 @@ Host behavior: - MAY dedupe identical `ui/update-model-context` calls - If multiple updates are received before the next user message, Host SHOULD only send the last update to the model - MAY display context updates to the user +- SHOULD declare an `updateModelContext` capability with supported modalities during initialization +- MUST respond with a JSON-RPC error if any content block type or `structuredContent` in the request is not declared in `hostCapabilities.updateModelContext` #### Notifications (Host → UI) diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index 66d5f830..f8424aba 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -818,6 +818,83 @@ describe("App <-> AppBridge integration", () => { }); }); +describe("Content block modality validation", () => { + let app: App; + let bridge: AppBridge; + let appTransport: InMemoryTransport; + let bridgeTransport: InMemoryTransport; + + afterEach(async () => { + await appTransport.close(); + await bridgeTransport.close(); + }); + + describe("Host-side validation", () => { + it("host rejects unsupported content in onmessage", async () => { + [appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair(); + const capabilities: McpUiHostCapabilities = { + ...testHostCapabilities, + message: { text: {} }, + }; + bridge = new AppBridge(null, testHostInfo, capabilities); + bridge.onmessage = async () => ({}); + app = new App(testAppInfo, {}, { autoResize: false }); + + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + await expect( + app.sendMessage({ + role: "user", + content: [ + { type: "image", data: "base64data", mimeType: "image/png" }, + ], + }), + ).rejects.toThrow("unsupported content type(s): image"); + }); + + it("host rejects unsupported content in onupdatemodelcontext", async () => { + [appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair(); + const capabilities: McpUiHostCapabilities = { + ...testHostCapabilities, + updateModelContext: { text: {} }, + }; + bridge = new AppBridge(null, testHostInfo, capabilities); + bridge.onupdatemodelcontext = async () => ({}); + app = new App(testAppInfo, {}, { autoResize: false }); + + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + await expect( + app.updateModelContext({ + content: [{ type: "audio", data: "base64", mimeType: "audio/mp3" }], + }), + ).rejects.toThrow("unsupported content type(s): audio"); + }); + + it("host rejects structuredContent when not declared", async () => { + [appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair(); + const capabilities: McpUiHostCapabilities = { + ...testHostCapabilities, + updateModelContext: { text: {} }, + }; + bridge = new AppBridge(null, testHostInfo, capabilities); + bridge.onupdatemodelcontext = async () => ({}); + app = new App(testAppInfo, {}, { autoResize: false }); + + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + await expect( + app.updateModelContext({ + structuredContent: { key: "value" }, + }), + ).rejects.toThrow("structuredContent is not supported"); + }); + }); +}); + describe("getToolUiResourceUri", () => { describe("new nested format (_meta.ui.resourceUri)", () => { it("extracts resourceUri from _meta.ui.resourceUri", () => { diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 0baab5d8..3740968a 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -79,6 +79,10 @@ import { McpUiRequestDisplayModeResult, McpUiResourcePermissions, } from "./types"; +import { + validateContentModalities, + buildValidationErrorMessage, +} from "./content-validation"; export * from "./types"; export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "./app"; import { RESOURCE_URI_META_KEY } from "./app"; @@ -436,6 +440,18 @@ export class AppBridge extends Protocol< this.setRequestHandler( McpUiMessageRequestSchema, async (request, extra) => { + const modalities = this._capabilities.message; + if (modalities !== undefined) { + const validation = validateContentModalities( + request.params.content, + modalities, + ); + if (!validation.valid) { + throw new Error( + buildValidationErrorMessage(validation, "ui/message"), + ); + } + } return callback(request.params, extra); }, ); @@ -574,6 +590,22 @@ export class AppBridge extends Protocol< this.setRequestHandler( McpUiUpdateModelContextRequestSchema, async (request, extra) => { + const modalities = this._capabilities.updateModelContext; + if (modalities !== undefined) { + const validation = validateContentModalities( + request.params.content, + modalities, + request.params.structuredContent !== undefined, + ); + if (!validation.valid) { + throw new Error( + buildValidationErrorMessage( + validation, + "ui/update-model-context", + ), + ); + } + } return callback(request.params, extra); }, ); diff --git a/src/content-validation.test.ts b/src/content-validation.test.ts new file mode 100644 index 00000000..16e6bbf9 --- /dev/null +++ b/src/content-validation.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect } from "bun:test"; +import { + validateContentModalities, + buildValidationErrorMessage, +} from "./content-validation"; + +describe("validateContentModalities", () => { + it("returns valid when modalities is undefined (backwards compat)", () => { + const result = validateContentModalities( + [{ type: "text", text: "hello" }], + undefined, + ); + expect(result.valid).toBe(true); + expect(result.unsupportedTypes).toEqual([]); + expect(result.structuredContentUnsupported).toBe(false); + }); + + it("returns valid when all content types are supported", () => { + const result = validateContentModalities( + [ + { type: "text", text: "hello" }, + { type: "image", data: "base64data", mimeType: "image/png" }, + ], + { text: {}, image: {} }, + ); + expect(result.valid).toBe(true); + expect(result.unsupportedTypes).toEqual([]); + }); + + it("returns invalid with unsupported types listed", () => { + const result = validateContentModalities( + [ + { type: "text", text: "hello" }, + { type: "image", data: "base64data", mimeType: "image/png" }, + ], + { text: {} }, + ); + expect(result.valid).toBe(false); + expect(result.unsupportedTypes).toEqual(["image"]); + }); + + it("handles resource_link → resourceLink mapping", () => { + const result = validateContentModalities( + [{ type: "resource_link", uri: "test://resource", name: "test" }], + { resourceLink: {} }, + ); + expect(result.valid).toBe(true); + expect(result.unsupportedTypes).toEqual([]); + }); + + it("returns invalid when resource_link is used without resourceLink modality", () => { + const result = validateContentModalities( + [{ type: "resource_link", uri: "test://resource", name: "test" }], + { text: {} }, + ); + expect(result.valid).toBe(false); + expect(result.unsupportedTypes).toEqual(["resource_link"]); + }); + + it("detects unsupported structuredContent", () => { + const result = validateContentModalities([], { text: {} }, true); + expect(result.valid).toBe(false); + expect(result.structuredContentUnsupported).toBe(true); + }); + + it("allows structuredContent when declared", () => { + const result = validateContentModalities( + [], + { text: {}, structuredContent: {} }, + true, + ); + expect(result.valid).toBe(true); + expect(result.structuredContentUnsupported).toBe(false); + }); + + it("handles undefined content array", () => { + const result = validateContentModalities(undefined, { text: {} }); + expect(result.valid).toBe(true); + expect(result.unsupportedTypes).toEqual([]); + }); + + it("handles empty content array", () => { + const result = validateContentModalities([], { text: {} }); + expect(result.valid).toBe(true); + expect(result.unsupportedTypes).toEqual([]); + }); + + it("deduplicates unsupported type names", () => { + const result = validateContentModalities( + [ + { type: "image", data: "a", mimeType: "image/png" }, + { type: "image", data: "b", mimeType: "image/png" }, + { type: "audio", data: "c", mimeType: "audio/mp3" }, + ], + { text: {} }, + ); + expect(result.valid).toBe(false); + expect(result.unsupportedTypes).toEqual(["image", "audio"]); + }); + + it("rejects all content types when modalities is empty object", () => { + const result = validateContentModalities( + [{ type: "text", text: "hello" }], + {}, + ); + expect(result.valid).toBe(false); + expect(result.unsupportedTypes).toEqual(["text"]); + }); + + it("handles audio content type", () => { + const result = validateContentModalities( + [{ type: "audio", data: "base64", mimeType: "audio/mp3" }], + { audio: {} }, + ); + expect(result.valid).toBe(true); + }); + + it("handles resource content type", () => { + const result = validateContentModalities( + [{ type: "resource", resource: { uri: "test://r", text: "content" } }], + { resource: {} }, + ); + expect(result.valid).toBe(true); + }); + + it("returns invalid for unknown content type", () => { + const result = validateContentModalities( + [{ type: "unknown_type" } as any], + { text: {} }, + ); + expect(result.valid).toBe(false); + expect(result.unsupportedTypes).toEqual(["unknown_type"]); + }); + + it("validates both content blocks and structuredContent together", () => { + const result = validateContentModalities( + [{ type: "image", data: "data", mimeType: "image/png" }], + { text: {} }, + true, + ); + expect(result.valid).toBe(false); + expect(result.unsupportedTypes).toEqual(["image"]); + expect(result.structuredContentUnsupported).toBe(true); + }); +}); + +describe("buildValidationErrorMessage", () => { + it("builds message for unsupported content types", () => { + const msg = buildValidationErrorMessage( + { + valid: false, + unsupportedTypes: ["image", "audio"], + structuredContentUnsupported: false, + }, + "ui/message", + ); + expect(msg).toBe("ui/message: unsupported content type(s): image, audio"); + }); + + it("builds message for unsupported structuredContent", () => { + const msg = buildValidationErrorMessage( + { + valid: false, + unsupportedTypes: [], + structuredContentUnsupported: true, + }, + "ui/update-model-context", + ); + expect(msg).toBe( + "ui/update-model-context: structuredContent is not supported", + ); + }); + + it("builds message with both unsupported types and structuredContent", () => { + const msg = buildValidationErrorMessage( + { + valid: false, + unsupportedTypes: ["image"], + structuredContentUnsupported: true, + }, + "ui/update-model-context", + ); + expect(msg).toBe( + "ui/update-model-context: unsupported content type(s): image; structuredContent is not supported", + ); + }); +}); diff --git a/src/content-validation.ts b/src/content-validation.ts new file mode 100644 index 00000000..a5bd9b63 --- /dev/null +++ b/src/content-validation.ts @@ -0,0 +1,104 @@ +import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js"; +import type { McpUiSupportedContentBlockModalities } from "./types"; + +/** + * Maps a ContentBlock `type` to its corresponding modality key + * in {@link McpUiSupportedContentBlockModalities}. + */ +const CONTENT_TYPE_TO_MODALITY: Record< + string, + keyof McpUiSupportedContentBlockModalities | undefined +> = { + text: "text", + image: "image", + audio: "audio", + resource: "resource", + resource_link: "resourceLink", +}; + +/** + * Result of validating content blocks against supported modalities. + */ +interface ContentValidationResult { + /** Whether all content blocks (and structuredContent if provided) are supported. */ + valid: boolean; + /** Deduplicated list of unsupported content block type names. */ + unsupportedTypes: string[]; + /** Whether structuredContent was provided but not supported. */ + structuredContentUnsupported: boolean; +} + +/** + * Validate content blocks and optional structuredContent against declared modalities. + * + * Returns `{ valid: true }` if `modalities` is `undefined` (backwards compatibility: + * host did not declare the capability, so all types are allowed). + * + * @param content - Array of content blocks to validate (may be undefined/empty) + * @param modalities - Supported modalities declared by the host, or undefined to skip validation + * @param hasStructuredContent - Whether structuredContent is present in the request + * @returns Validation result with details about unsupported types + */ +export function validateContentModalities( + content: ContentBlock[] | undefined, + modalities: McpUiSupportedContentBlockModalities | undefined, + hasStructuredContent: boolean = false, +): ContentValidationResult { + // Backwards compatibility: if modalities is undefined, skip validation entirely + if (modalities === undefined) { + return { + valid: true, + unsupportedTypes: [], + structuredContentUnsupported: false, + }; + } + + const unsupportedTypes = new Set(); + let structuredContentUnsupported = false; + + // Check each content block + if (content) { + for (const block of content) { + const modalityKey = + CONTENT_TYPE_TO_MODALITY[(block as { type: string }).type]; + if (modalityKey === undefined || !(modalityKey in modalities)) { + unsupportedTypes.add((block as { type: string }).type); + } + } + } + + // Check structuredContent + if (hasStructuredContent && !("structuredContent" in modalities)) { + structuredContentUnsupported = true; + } + + const valid = unsupportedTypes.size === 0 && !structuredContentUnsupported; + return { + valid, + unsupportedTypes: [...unsupportedTypes], + structuredContentUnsupported, + }; +} + +/** + * Build a human-readable error message from a failed validation result. + * + * @param result - The validation result (must have `valid: false`) + * @param method - The protocol method name for context in the error message + * @returns Error message string + */ +export function buildValidationErrorMessage( + result: ContentValidationResult, + method: string, +): string { + const parts: string[] = []; + if (result.unsupportedTypes.length > 0) { + parts.push( + `unsupported content type(s): ${result.unsupportedTypes.join(", ")}`, + ); + } + if (result.structuredContentUnsupported) { + parts.push("structuredContent is not supported"); + } + return `${method}: ${parts.join("; ")}`; +}