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
95 changes: 67 additions & 28 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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

Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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

Expand Down Expand Up @@ -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)

Expand Down
77 changes: 77 additions & 0 deletions src/app-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
32 changes: 32 additions & 0 deletions src/app-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
},
);
Expand Down Expand Up @@ -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);
},
);
Expand Down
Loading
Loading