Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 90 additions & 82 deletions package-lock.json

Large diffs are not rendered by default.

27 changes: 23 additions & 4 deletions specification/draft/apps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,23 @@ 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?: {};
}
```

Expand Down Expand Up @@ -926,10 +943,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)
}
}

Expand All @@ -953,6 +968,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

Expand Down Expand Up @@ -1029,6 +1046,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 in the request is not declared in `hostCapabilities.updateModelContext`

#### Notifications (Host → UI)

Expand Down
57 changes: 57 additions & 0 deletions src/app-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,63 @@ 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");
});
});
});

describe("getToolUiResourceUri", () => {
describe("new nested format (_meta.ui.resourceUri)", () => {
it("extracts resourceUri from _meta.ui.resourceUri", () => {
Expand Down
31 changes: 31 additions & 0 deletions src/app-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
},
);
Expand Down Expand Up @@ -574,6 +590,21 @@ 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,
);
if (!validation.valid) {
throw new Error(
buildValidationErrorMessage(
validation,
"ui/update-model-context",
),
);
}
}
return callback(request.params, extra);
},
);
Expand Down
130 changes: 130 additions & 0 deletions src/content-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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([]);
});

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("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"]);
});
});

describe("buildValidationErrorMessage", () => {
it("builds message for unsupported content types", () => {
const msg = buildValidationErrorMessage(
{
valid: false,
unsupportedTypes: ["image", "audio"],
},
"ui/message",
);
expect(msg).toBe("ui/message: unsupported content type(s): image, audio");
});
});
89 changes: 89 additions & 0 deletions src/content-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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 are supported. */
valid: boolean;
/** Deduplicated list of unsupported content block type names. */
unsupportedTypes: string[];
}

/**
* Validate content blocks 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
* @returns Validation result with details about unsupported types
*/
export function validateContentModalities(
content: ContentBlock[] | undefined,
modalities: McpUiSupportedContentBlockModalities | undefined,
): ContentValidationResult {
// Backwards compatibility: if modalities is undefined, skip validation entirely
if (modalities === undefined) {
return {
valid: true,
unsupportedTypes: [],
};
}

const unsupportedTypes = new Set<string>();

// 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);
}
}
}

const valid = unsupportedTypes.size === 0;
return {
valid,
unsupportedTypes: [...unsupportedTypes],
};
}

/**
* 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(", ")}`,
);
}
return `${method}: ${parts.join("; ")}`;
}
Loading
Loading