diff --git a/package-lock.json b/package-lock.json index 7aa9ef932..8f94322f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3155,13 +3155,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" @@ -3254,6 +3254,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", @@ -4403,6 +4416,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", @@ -6919,6 +6945,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", @@ -7266,13 +7305,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" @@ -7288,13 +7327,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" @@ -7307,9 +7346,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": { @@ -7517,6 +7556,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", @@ -9623,19 +9675,6 @@ } } }, - "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", diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index cb73cabe1..3f8d47ada 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -681,6 +681,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?: {}; } ``` @@ -1023,10 +1042,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) } } @@ -1050,6 +1067,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 @@ -1118,6 +1137,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 → View) diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index 875c10e49..951f35957 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -820,6 +820,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 3322a0dda..83917f323 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -80,6 +80,10 @@ import { McpUiResourcePermissions, McpUiToolMeta, } 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"; @@ -552,6 +556,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); }, ); @@ -732,6 +748,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 000000000..16e6bbf96 --- /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 000000000..a5bd9b637 --- /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("; ")}`; +}